Implement submitting/withdrawing budgets and email sending
This commit is contained in:
parent
2a5bf7ad93
commit
1afc3f3db7
86
ssmain/email.py
Normal file
86
ssmain/email.py
Normal 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)
|
@ -890,7 +890,7 @@
|
|||||||
margin: 30px 0;
|
margin: 30px 0;
|
||||||
Margin: 30px 0; }
|
Margin: 30px 0; }
|
||||||
pre code {
|
pre code {
|
||||||
color: #cacaca; }
|
color: #000000; }
|
||||||
pre code span.callout {
|
pre code span.callout {
|
||||||
color: #8a8a8a;
|
color: #8a8a8a;
|
||||||
font-weight: bold; }
|
font-weight: bold; }
|
||||||
@ -1424,7 +1424,7 @@
|
|||||||
<th>
|
<th>
|
||||||
<p class="text-center footercopy">
|
<p class="text-center footercopy">
|
||||||
{% block footer %}{% endblock %}
|
{% block footer %}{% endblock %}
|
||||||
© Copyright {{ import('datetime').datetime.now().strftime('%Y') }} MUMUS Inc. All Rights Reserved.<br>
|
© 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>.
|
Design by <a href="https://www.sendwithus.com/resources/templates/meow">SendWithUs</a>.
|
||||||
</p>
|
</p>
|
||||||
</th>
|
</th>
|
48
ssmain/jinja2/ssmain/email/rendered.html
Normal file
48
ssmain/jinja2/ssmain/email/rendered.html
Normal 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>
|
||||||
|
 
|
||||||
|
</th>
|
||||||
|
<th class="expander"></th>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
{% endblock content %}
|
@ -1,4 +1,4 @@
|
|||||||
{% extends 'ssmembership/email/base.html' %}
|
{% extends 'ssmain/email/base.html' %}
|
||||||
|
|
||||||
{#
|
{#
|
||||||
Society Self-Service
|
Society Self-Service
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
{% extends 'sspromotions/email/base.html' %}
|
{% extends 'ssmain/email/base.html' %}
|
||||||
|
|
||||||
{#
|
{#
|
||||||
Society Self-Service
|
Society Self-Service
|
||||||
|
@ -33,7 +33,7 @@
|
|||||||
{% for revision in budgets %}
|
{% for revision in budgets %}
|
||||||
<tr>
|
<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}) }}">{{ 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>
|
<td>
|
||||||
<a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
|
<a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
|
||||||
</td>
|
</td>
|
||||||
|
@ -24,10 +24,18 @@
|
|||||||
<h1>{{ revision.name }}</h1>
|
<h1>{{ revision.name }}</h1>
|
||||||
|
|
||||||
{% if is_latest %}
|
{% 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>
|
<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 %}
|
{% else %}
|
||||||
<div class="ui warning message">
|
<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>
|
<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="content">
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<i class="user circle icon"></i>
|
<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">
|
<div class="date">
|
||||||
{{ localtime(item.time) }}
|
{{ localtime(item.time) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="extra text">
|
<div class="extra text">
|
||||||
{{ item.content }}
|
{{ item.content|markdown }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@ -148,7 +156,15 @@
|
|||||||
<div class="content">
|
<div class="content">
|
||||||
<div class="summary">
|
<div class="summary">
|
||||||
<i class="user circle icon"></i>
|
<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">
|
<div class="date">
|
||||||
{{ localtime(item.time) }}
|
{{ localtime(item.time) }}
|
||||||
</div>
|
</div>
|
||||||
|
7
sstreasury/jinja2/sstreasury/email/commented.md
Normal file
7
sstreasury/jinja2/sstreasury/email/commented.md
Normal 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}) }}
|
3
sstreasury/jinja2/sstreasury/email/submitted_drafter.md
Normal file
3
sstreasury/jinja2/sstreasury/email/submitted_drafter.md
Normal 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}) }}
|
@ -0,0 +1,3 @@
|
|||||||
|
A budget titled *{{ revision.name }}* has been submitted for your review.
|
||||||
|
|
||||||
|
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}
|
@ -39,7 +39,18 @@ class BudgetState(Enum):
|
|||||||
AWAIT_REVIEW = 30, 'Awaiting Treasury review'
|
AWAIT_REVIEW = 30, 'Awaiting Treasury review'
|
||||||
ENDORSED = 40, 'Endorsed by Treasury, awaiting committee approval'
|
ENDORSED = 40, 'Endorsed by Treasury, awaiting committee approval'
|
||||||
APPROVED = 50, 'Approved'
|
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):
|
def __new__(cls, value, description):
|
||||||
obj = object.__new__(cls)
|
obj = object.__new__(cls)
|
||||||
@ -66,5 +77,13 @@ class BudgetRevision(models.Model):
|
|||||||
expense_no_emergency_fund = models.BooleanField()
|
expense_no_emergency_fund = models.BooleanField()
|
||||||
expense_comments = models.TextField()
|
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:
|
class Meta:
|
||||||
ordering = ['id']
|
ordering = ['id']
|
||||||
|
@ -26,6 +26,7 @@ from django.utils import timezone
|
|||||||
from django.views import generic
|
from django.views import generic
|
||||||
|
|
||||||
from . import models
|
from . import models
|
||||||
|
from ssmain.email import Emailer
|
||||||
|
|
||||||
import itertools
|
import itertools
|
||||||
import json
|
import json
|
||||||
@ -113,6 +114,7 @@ def budget_new(request):
|
|||||||
revision = models.BudgetRevision()
|
revision = models.BudgetRevision()
|
||||||
revision.author = request.user
|
revision.author = request.user
|
||||||
revision.time = timezone.now()
|
revision.time = timezone.now()
|
||||||
|
revision.action = models.BudgetAction.CREATE.value
|
||||||
revision = revision_from_form(budget, revision, request.POST)
|
revision = revision_from_form(budget, revision, request.POST)
|
||||||
|
|
||||||
if request.POST['submit'] == 'Save':
|
if request.POST['submit'] == 'Save':
|
||||||
@ -146,6 +148,7 @@ def budget_edit(request, id):
|
|||||||
revision = models.BudgetRevision()
|
revision = models.BudgetRevision()
|
||||||
revision.author = request.user
|
revision.author = request.user
|
||||||
revision.time = timezone.now()
|
revision.time = timezone.now()
|
||||||
|
revision.action = models.BudgetAction.EDIT.value
|
||||||
revision = revision_from_form(budget, revision, request.POST)
|
revision = revision_from_form(budget, revision, request.POST)
|
||||||
|
|
||||||
if request.POST['submit'] == 'Save':
|
if request.POST['submit'] == 'Save':
|
||||||
@ -170,11 +173,51 @@ def budget_action(request, id):
|
|||||||
raise PermissionDenied
|
raise PermissionDenied
|
||||||
|
|
||||||
if request.POST['action'] == 'Comment':
|
if request.POST['action'] == 'Comment':
|
||||||
comment = models.BudgetComment()
|
with transaction.atomic():
|
||||||
comment.budget = budget
|
comment = models.BudgetComment()
|
||||||
comment.author = request.user
|
comment.budget = budget
|
||||||
comment.time = timezone.now()
|
comment.author = request.user
|
||||||
comment.content = request.POST['comment']
|
comment.time = timezone.now()
|
||||||
comment.save()
|
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}))
|
return redirect(reverse('budget_view', kwargs={'id': budget.id}))
|
||||||
|
Loading…
Reference in New Issue
Block a user