Implement submitting/withdrawing budgets and email sending

This commit is contained in:
Yingtong Li 2019-06-19 19:46:24 +10:00
parent 2a5bf7ad93
commit 1afc3f3db7
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
12 changed files with 243 additions and 18 deletions

86
ssmain/email.py Normal file
View File

@ -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 <https://www.gnu.org/licenses/>.
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)

View File

@ -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 @@
<th>
<p class="text-center footercopy">
{% block footer %}{% endblock %}
&#xA9; Copyright {{ import('datetime').datetime.now().strftime('%Y') }} MUMUS Inc. All Rights Reserved.<br>
&#xA9; Copyright {{ import('datetime').datetime.now().strftime('%Y') }} {{ import('django.conf').settings.ORG_NAME }}. All Rights Reserved.<br>
Design by <a href="https://www.sendwithus.com/resources/templates/meow">SendWithUs</a>.
</p>
</th>

View File

@ -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 <https://www.gnu.org/licenses/>.
#}
{% block content %}
<tr> <!-- main Email content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<th>
{{ email_content }}
</th>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
<tr> <!-- This container adds whitespace gap at the bottom of main content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<th>
&#xA0;
</th>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
{% endblock content %}

View File

@ -1,4 +1,4 @@
{% extends 'ssmembership/email/base.html' %}
{% extends 'ssmain/email/base.html' %}
{#
Society Self-Service

View File

@ -1,4 +1,4 @@
{% extends 'sspromotions/email/base.html' %}
{% extends 'ssmain/email/base.html' %}
{#
Society Self-Service

View File

@ -33,7 +33,7 @@
{% for revision in budgets %}
<tr>
<td class="selectable"><a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">{{ revision.name }}</a></td>
<td class="selectable"><a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">{{ import('sstreasury.models').BudgetState(revision.state).description }}</a></td>
<td class="selectable"><a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">{{ revision.get_state_display() }}</a></td>
<td>
<a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>

View File

@ -24,10 +24,18 @@
<h1>{{ revision.name }}</h1>
{% if is_latest %}
<div>
<form class="ui form" action="{{ url('budget_action', kwargs={'id': revision.budget.id}) }}" method="POST">
<span class="ui header">Status: {{ revision.get_state_display() }}</span>
<a class="ui mini labeled right floated icon button" href="{{ url('budget_edit', kwargs={'id': revision.budget.id}) }}"><i class="edit icon"></i> Edit</a>
</div>
{% if revision.state == import('sstreasury.models').BudgetState.DRAFT.value or revision.state == import('sstreasury.models').BudgetState.RESUBMIT.value %}
<button class="ui mini labeled primary icon button" type="submit" name="action" value="Submit" style="margin-left: 1em;" onclick="return confirm('Are you sure you want to submit this budget for Treasury approval? You will not be able to make any additional changes without withdrawing the budget.');"><i class="paper plane icon"></i> Submit</button>
<a class="ui mini labeled right floated icon button" href="{{ url('budget_edit', kwargs={'id': revision.budget.id}) }}"><i class="edit icon"></i> Edit</a>
{% elif revision.state != import('sstreasury.models').BudgetState.APPROVED.value %}
<button class="ui mini labeled basic red icon button" type="submit" name="action" value="Withdraw" style="margin-left: 1em;" onclick="return confirm('Are you sure you want to withdraw this budget from being considered for approval? The budget will be reverted to a draft.');"><i class="undo icon"></i> Withdraw</button>
{% endif %}
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
{% else %}
<div class="ui warning message">
<p>You are viewing an older version of this budget. To make any changes, <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">click here</a> to return to the current version.</p>
@ -130,13 +138,13 @@
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name}}</a> commented
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> commented
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
<div class="extra text">
{{ item.content }}
{{ item.content|markdown }}
</div>
</div>
</div>
@ -148,7 +156,15 @@
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name}}</a> edited the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% if item.action == import('sstreasury.models').BudgetAction.CREATE.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> created the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% elif item.action == import('sstreasury.models').BudgetAction.EDIT.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> edited the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% elif item.action == import('sstreasury.models').BudgetAction.UPDATE_STATE.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> changed the state to: {{ item.get_state_display() }}
{% else %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> modified the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% endif %}
<div class="date">
{{ localtime(item.time) }}
</div>

View File

@ -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}) }}

View File

@ -0,0 +1,3 @@
Your budget titled *{{ revision.name }}* has been submitted for Treasury review.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
A budget titled *{{ revision.name }}* has been submitted for your review.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -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']

View File

@ -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}))