From 1afc3f3db71353fadce6d630ef4ac6514225c3bf Mon Sep 17 00:00:00 2001 From: Yingtong Li Date: Wed, 19 Jun 2019 19:46:24 +1000 Subject: [PATCH] Implement submitting/withdrawing budgets and email sending --- ssmain/email.py | 86 +++++++++++++++++++ .../jinja2/ssmain}/email/base.html | 4 +- ssmain/jinja2/ssmain/email/rendered.html | 48 +++++++++++ .../jinja2/ssmembership/email/onboard.html | 2 +- .../jinja2/sspromotions/email/bulletin.html | 2 +- sstreasury/jinja2/sstreasury/budget_list.html | 2 +- sstreasury/jinja2/sstreasury/budget_view.html | 28 ++++-- .../jinja2/sstreasury/email/commented.md | 7 ++ .../sstreasury/email/submitted_drafter.md | 3 + .../sstreasury/email/submitted_treasurer.md | 3 + sstreasury/models.py | 21 ++++- sstreasury/views.py | 55 ++++++++++-- 12 files changed, 243 insertions(+), 18 deletions(-) create mode 100644 ssmain/email.py rename {sspromotions/jinja2/sspromotions => ssmain/jinja2/ssmain}/email/base.html (99%) create mode 100644 ssmain/jinja2/ssmain/email/rendered.html create mode 100644 sstreasury/jinja2/sstreasury/email/commented.md create mode 100644 sstreasury/jinja2/sstreasury/email/submitted_drafter.md create mode 100644 sstreasury/jinja2/sstreasury/email/submitted_treasurer.md diff --git a/ssmain/email.py b/ssmain/email.py new file mode 100644 index 0000000..1fe82bc --- /dev/null +++ b/ssmain/email.py @@ -0,0 +1,86 @@ +# Society Self-Service +# Copyright © 2018-2019 Yingtong Li (RunasSudo) +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU Affero General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import boto3 +from botocore.exceptions import ClientError +import premailer + +from django.conf import settings +from django.template import loader +from jinja2 import Markup + +import markdown + +class Emailer: + def __init__(self): + self.client = boto3.client('ses', aws_access_key_id=settings.AWS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET, region_name=settings.AWS_REGION) + self.template = loader.get_template('ssmain/email/rendered.html') + + def markdown(self, x): + return markdown.markdown(x, extensions=['mdx_urlize', 'fenced_code']) + + def boto3_send(self, *args, **kwargs): + for i in range(0, 10): + try: + self.client.send_email(*args, **kwargs) + return + except ClientError as e: + if e['Error']['Code'] == 'Throttling' and e['Error']['Message'] == 'Maximum sending rate exceeded.': + wait_time = max(10*(2**i), 5000) + #self.stdout.write(self.style.NOTICE('Reached maximum sending rate, waiting {} ms'.format(wait_time))) + time.sleep(wait_time/1000) + else: + raise e + raise Exception('Reached maximum number of retries') + + def send_raw_mail(self, recipients, subject, content_txt, content_html): + self.boto3_send( + Destination={ + 'ToAddresses': recipients, + }, + Message={ + 'Body': { + 'Html': { + 'Charset': 'utf-8', + 'Data': content_html, + }, + 'Text': { + 'Charset': 'utf-8', + 'Data': content_txt, + }, + }, + 'Subject': { + 'Charset': 'utf-8', + 'Data': subject, + }, + }, + Source='{} <{}>'.format(settings.ORG_NAME, settings.AWS_SENDER_EMAIL), + ) + + def render_mail(self, template_loc, params={}): + params['baseurl'] = 'https://' + settings.ALLOWED_HOSTS[0] + + template = loader.get_template(template_loc) + content_txt = template.render(params) + + content_markdown = self.markdown(content_txt) + content_html = self.template.render({'email_content': Markup(content_markdown)}) + + return content_txt, content_html + + def send_mail(self, recipients, subject, template_loc, params): + content_txt, content_html = self.render_mail(template_loc, params) + self.send_raw_mail(recipients, subject, content_txt, content_html) diff --git a/sspromotions/jinja2/sspromotions/email/base.html b/ssmain/jinja2/ssmain/email/base.html similarity index 99% rename from sspromotions/jinja2/sspromotions/email/base.html rename to ssmain/jinja2/ssmain/email/base.html index 0964a71..fac8c63 100644 --- a/sspromotions/jinja2/sspromotions/email/base.html +++ b/ssmain/jinja2/ssmain/email/base.html @@ -890,7 +890,7 @@ margin: 30px 0; Margin: 30px 0; } pre code { - color: #cacaca; } + color: #000000; } pre code span.callout { color: #8a8a8a; font-weight: bold; } @@ -1424,7 +1424,7 @@

{% block footer %}{% endblock %} - © Copyright {{ import('datetime').datetime.now().strftime('%Y') }} MUMUS Inc. All Rights Reserved.
+ © Copyright {{ import('datetime').datetime.now().strftime('%Y') }} {{ import('django.conf').settings.ORG_NAME }}. All Rights Reserved.
Design by SendWithUs.

diff --git a/ssmain/jinja2/ssmain/email/rendered.html b/ssmain/jinja2/ssmain/email/rendered.html new file mode 100644 index 0000000..3e89e58 --- /dev/null +++ b/ssmain/jinja2/ssmain/email/rendered.html @@ -0,0 +1,48 @@ +{% extends 'ssmain/email/base.html' %} + +{# + Society Self-Service + Copyright © 2018-2019 Yingtong Li (RunasSudo) + + Design by SendWithUs (Apache 2.0 licence) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . +#} + +{% block content %} + + + + + + + +
+ {{ email_content }} +
+ + + + + + + + + +
+   +
+ + +{% endblock content %} diff --git a/ssmembership/jinja2/ssmembership/email/onboard.html b/ssmembership/jinja2/ssmembership/email/onboard.html index f9f5cde..448faee 100644 --- a/ssmembership/jinja2/ssmembership/email/onboard.html +++ b/ssmembership/jinja2/ssmembership/email/onboard.html @@ -1,4 +1,4 @@ -{% extends 'ssmembership/email/base.html' %} +{% extends 'ssmain/email/base.html' %} {# Society Self-Service diff --git a/sspromotions/jinja2/sspromotions/email/bulletin.html b/sspromotions/jinja2/sspromotions/email/bulletin.html index 11f06bb..c8d3162 100644 --- a/sspromotions/jinja2/sspromotions/email/bulletin.html +++ b/sspromotions/jinja2/sspromotions/email/bulletin.html @@ -1,4 +1,4 @@ -{% extends 'sspromotions/email/base.html' %} +{% extends 'ssmain/email/base.html' %} {# Society Self-Service diff --git a/sstreasury/jinja2/sstreasury/budget_list.html b/sstreasury/jinja2/sstreasury/budget_list.html index 15c8b76..54cef10 100644 --- a/sstreasury/jinja2/sstreasury/budget_list.html +++ b/sstreasury/jinja2/sstreasury/budget_list.html @@ -33,7 +33,7 @@ {% for revision in budgets %} {{ revision.name }} - {{ import('sstreasury.models').BudgetState(revision.state).description }} + {{ revision.get_state_display() }} diff --git a/sstreasury/jinja2/sstreasury/budget_view.html b/sstreasury/jinja2/sstreasury/budget_view.html index 396b13b..a09d1c6 100644 --- a/sstreasury/jinja2/sstreasury/budget_view.html +++ b/sstreasury/jinja2/sstreasury/budget_view.html @@ -24,10 +24,18 @@

{{ revision.name }}

{% if is_latest %} -
+
Status: {{ revision.get_state_display() }} - Edit -
+ + {% if revision.state == import('sstreasury.models').BudgetState.DRAFT.value or revision.state == import('sstreasury.models').BudgetState.RESUBMIT.value %} + + Edit + {% elif revision.state != import('sstreasury.models').BudgetState.APPROVED.value %} + + {% endif %} + + + {% else %}

You are viewing an older version of this budget. To make any changes, click here to return to the current version.

@@ -130,13 +138,13 @@
- {{ item.content }} + {{ item.content|markdown }}
@@ -148,7 +156,15 @@
- {{ item.author.first_name }} {{ item.author.last_name}} edited the budget (view) + {% if item.action == import('sstreasury.models').BudgetAction.CREATE.value %} + {{ item.author.first_name }} {{ item.author.last_name }} created the budget (view) + {% elif item.action == import('sstreasury.models').BudgetAction.EDIT.value %} + {{ item.author.first_name }} {{ item.author.last_name }} edited the budget (view) + {% elif item.action == import('sstreasury.models').BudgetAction.UPDATE_STATE.value %} + {{ item.author.first_name }} {{ item.author.last_name }} changed the state to: {{ item.get_state_display() }} + {% else %} + {{ item.author.first_name }} {{ item.author.last_name }} modified the budget (view) + {% endif %}
{{ localtime(item.time) }}
diff --git a/sstreasury/jinja2/sstreasury/email/commented.md b/sstreasury/jinja2/sstreasury/email/commented.md new file mode 100644 index 0000000..b1f8582 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/commented.md @@ -0,0 +1,7 @@ +{{ comment.author.first_name }} {{ comment.author.last_name }} made a new comment on the budget *{{ revision.name }}*: + +``` +{{ comment.content }} +``` + +{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }} diff --git a/sstreasury/jinja2/sstreasury/email/submitted_drafter.md b/sstreasury/jinja2/sstreasury/email/submitted_drafter.md new file mode 100644 index 0000000..f331bd2 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/submitted_drafter.md @@ -0,0 +1,3 @@ +Your budget titled *{{ revision.name }}* has been submitted for Treasury review. + +{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }} diff --git a/sstreasury/jinja2/sstreasury/email/submitted_treasurer.md b/sstreasury/jinja2/sstreasury/email/submitted_treasurer.md new file mode 100644 index 0000000..e62b9d7 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/submitted_treasurer.md @@ -0,0 +1,3 @@ +A budget titled *{{ revision.name }}* has been submitted for your review. + +{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }} diff --git a/sstreasury/models.py b/sstreasury/models.py index 007a3e1..429dc5c 100644 --- a/sstreasury/models.py +++ b/sstreasury/models.py @@ -39,7 +39,18 @@ class BudgetState(Enum): AWAIT_REVIEW = 30, 'Awaiting Treasury review' ENDORSED = 40, 'Endorsed by Treasury, awaiting committee approval' APPROVED = 50, 'Approved' - CANCELLED = 60, 'Cancelled' + #CANCELLED = 60, 'Cancelled' + + def __new__(cls, value, description): + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + +class BudgetAction(Enum): + CREATE = 5, 'Created' + EDIT = 10, 'Edited' + UPDATE_STATE = 20, 'Updated state' def __new__(cls, value, description): obj = object.__new__(cls) @@ -66,5 +77,13 @@ class BudgetRevision(models.Model): expense_no_emergency_fund = models.BooleanField() expense_comments = models.TextField() + action = models.IntegerField(choices=[(v.value, v.description) for v in BudgetAction]) + + def copy(self): + contributors = list(self.contributors.all()) + self.pk, self.id = None, None + self.save() + self.contributors.add(*contributors) + class Meta: ordering = ['id'] diff --git a/sstreasury/views.py b/sstreasury/views.py index adee9ac..2e9a54e 100644 --- a/sstreasury/views.py +++ b/sstreasury/views.py @@ -26,6 +26,7 @@ from django.utils import timezone from django.views import generic from . import models +from ssmain.email import Emailer import itertools import json @@ -113,6 +114,7 @@ def budget_new(request): revision = models.BudgetRevision() revision.author = request.user revision.time = timezone.now() + revision.action = models.BudgetAction.CREATE.value revision = revision_from_form(budget, revision, request.POST) if request.POST['submit'] == 'Save': @@ -146,6 +148,7 @@ def budget_edit(request, id): revision = models.BudgetRevision() revision.author = request.user revision.time = timezone.now() + revision.action = models.BudgetAction.EDIT.value revision = revision_from_form(budget, revision, request.POST) if request.POST['submit'] == 'Save': @@ -170,11 +173,51 @@ def budget_action(request, id): raise PermissionDenied if request.POST['action'] == 'Comment': - comment = models.BudgetComment() - comment.budget = budget - comment.author = request.user - comment.time = timezone.now() - comment.content = request.POST['comment'] - comment.save() + with transaction.atomic(): + comment = models.BudgetComment() + comment.budget = budget + comment.author = request.user + comment.time = timezone.now() + comment.content = request.POST['comment'] + comment.save() + + emailer = Emailer() + for user in User.objects.filter(groups__name='Treasury'): + if user != request.user: + emailer.send_mail([user.email], 'New comment on budget: {}'.format(revision.name), 'sstreasury/email/commented.md', {'revision': revision, 'comment': comment}) + for user in revision.contributors.all(): + if user != request.user: + emailer.send_mail([user.email], 'New comment on budget: {}'.format(revision.name), 'sstreasury/email/commented.md', {'revision': revision, 'comment': comment}) + + if request.POST['action'] == 'Submit': + if revision.state != models.BudgetState.DRAFT.value and revision.state != models.BudgetState.RESUBMIT.value: + raise PermissionDenied + + with transaction.atomic(): + # Copy revision + revision.copy() + revision.author = request.user + revision.time = timezone.now() + revision.state = models.BudgetState.AWAIT_REVIEW.value + revision.action = models.BudgetAction.UPDATE_STATE.value + revision.save() + + emailer = Emailer() + for user in User.objects.filter(groups__name='Treasury'): + emailer.send_mail([user.email], 'Budget submitted: {}'.format(revision.name), 'sstreasury/email/submitted_treasurer.md', {'revision': revision}) + for user in revision.contributors.all(): + emailer.send_mail([user.email], 'Budget submitted: {}'.format(revision.name), 'sstreasury/email/submitted_drafter.md', {'revision': revision}) + + if request.POST['action'] == 'Withdraw': + if revision.state == models.BudgetState.DRAFT.value or revision.state == models.BudgetState.RESUBMIT.value or revision.state == models.BudgetState.APPROVED.value: + raise PermissionDenied + + # Copy revision + revision.copy() + revision.author = request.user + revision.time = timezone.now() + revision.state = models.BudgetState.DRAFT.value + revision.action = models.BudgetAction.UPDATE_STATE.value + revision.save() return redirect(reverse('budget_view', kwargs={'id': budget.id}))