From b70ef9b8f6ca40494fb2754807090f6324bc01f5 Mon Sep 17 00:00:00 2001 From: Yingtong Li Date: Sat, 28 Dec 2019 23:23:56 +1100 Subject: [PATCH] Reimbursement claim workflow --- sstreasury/jinja2/sstreasury/budget_edit.html | 2 +- .../jinja2/sstreasury/budget_print.html | 2 +- sstreasury/jinja2/sstreasury/budget_view.html | 6 +- sstreasury/jinja2/sstreasury/claim_edit.html | 6 +- sstreasury/jinja2/sstreasury/claim_print.html | 121 ++++++++++ sstreasury/jinja2/sstreasury/claim_view.html | 49 ++++- .../jinja2/sstreasury/email/claim_approved.md | 3 + .../sstreasury/email/claim_commented.md | 7 + .../jinja2/sstreasury/email/claim_returned.md | 3 + .../email/claim_submitted_drafter.md | 3 + .../email/claim_submitted_treasurer.md | 3 + sstreasury/models.py | 1 + sstreasury/urls.py | 3 + sstreasury/views.py | 207 ++++++++++++++++-- 14 files changed, 387 insertions(+), 29 deletions(-) create mode 100644 sstreasury/jinja2/sstreasury/claim_print.html create mode 100644 sstreasury/jinja2/sstreasury/email/claim_approved.md create mode 100644 sstreasury/jinja2/sstreasury/email/claim_commented.md create mode 100644 sstreasury/jinja2/sstreasury/email/claim_returned.md create mode 100644 sstreasury/jinja2/sstreasury/email/claim_submitted_drafter.md create mode 100644 sstreasury/jinja2/sstreasury/email/claim_submitted_treasurer.md diff --git a/sstreasury/jinja2/sstreasury/budget_edit.html b/sstreasury/jinja2/sstreasury/budget_edit.html index 830ca00..8249e7b 100644 --- a/sstreasury/jinja2/sstreasury/budget_edit.html +++ b/sstreasury/jinja2/sstreasury/budget_edit.html @@ -26,7 +26,7 @@
- +
diff --git a/sstreasury/jinja2/sstreasury/budget_print.html b/sstreasury/jinja2/sstreasury/budget_print.html index 1a5dab5..b653b7c 100644 --- a/sstreasury/jinja2/sstreasury/budget_print.html +++ b/sstreasury/jinja2/sstreasury/budget_print.html @@ -35,7 +35,7 @@ ID - {{ revision.budget.id }} + BU-{{ revision.budget.id }} Name diff --git a/sstreasury/jinja2/sstreasury/budget_view.html b/sstreasury/jinja2/sstreasury/budget_view.html index 44f5f2e..7334869 100644 --- a/sstreasury/jinja2/sstreasury/budget_view.html +++ b/sstreasury/jinja2/sstreasury/budget_view.html @@ -30,11 +30,11 @@ {% if revision.state == import('sstreasury.models').BudgetState.DRAFT.value or revision.state == import('sstreasury.models').BudgetState.RESUBMIT.value %} - {% if request.user.groups.filter(name='Secretary').exists() %} + {#{% if request.user.groups.filter(name='Secretary').exists() %} - {% endif %} + {% endif %}#} {% elif revision.state == import('sstreasury.models').BudgetState.AWAIT_REVIEW.value and request.user.groups.filter(name='Treasury').exists() %} @@ -76,7 +76,7 @@ ID - {{ revision.budget.id }} + BU-{{ revision.budget.id }} Name diff --git a/sstreasury/jinja2/sstreasury/claim_edit.html b/sstreasury/jinja2/sstreasury/claim_edit.html index 352e762..098492a 100644 --- a/sstreasury/jinja2/sstreasury/claim_edit.html +++ b/sstreasury/jinja2/sstreasury/claim_edit.html @@ -26,7 +26,7 @@
- +
@@ -43,6 +43,10 @@
+
+ + +
diff --git a/sstreasury/jinja2/sstreasury/claim_print.html b/sstreasury/jinja2/sstreasury/claim_print.html new file mode 100644 index 0000000..2d6866d --- /dev/null +++ b/sstreasury/jinja2/sstreasury/claim_print.html @@ -0,0 +1,121 @@ +{% extends 'ssmain/base.html' %} + +{# + 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 . +#} + +{% block title %}{{ claim.purpose }}{% endblock %} + +{% block content %} +

{{ claim.purpose }}

+ + Status: {{ claim.get_state_display() }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
IDRE-{{ claim.id }}
Purpose{{ claim.purpose }}
Expenditure date{{ claim.date }}
Claimee + +
Budget ID{{ claim.budget_id }}
Comments{{ claim.comments }}
Items +
+
+{% endblock %} + +{% block head %} + {{ super() }} + + + + + +{% endblock %} + +{% block script %} + {{ super() }} + + + + + + +{% endblock %} diff --git a/sstreasury/jinja2/sstreasury/claim_view.html b/sstreasury/jinja2/sstreasury/claim_view.html index 999474d..d9c0fc0 100644 --- a/sstreasury/jinja2/sstreasury/claim_view.html +++ b/sstreasury/jinja2/sstreasury/claim_view.html @@ -23,17 +23,42 @@ {% block maincontent %}

{{ claim.purpose }}

- + Status: {{ claim.get_state_display() }} - {# TODO #} + {% if claim.state == import('sstreasury.models').ClaimState.DRAFT.value or claim.state == import('sstreasury.models').ClaimState.RESUBMIT.value %} + + {% elif claim.state == import('sstreasury.models').ClaimState.AWAIT_REVIEW.value and request.user.groups.filter(name='Treasury').exists() %} + + + + {% elif claim.state == import('sstreasury.models').ClaimState.APPROVED.value or claim.state == import('sstreasury.models').ClaimState.PAID.value %} + {# Blank #} + {% else %} + + {% endif %} + + {% if claim.state == import('sstreasury.models').ClaimState.DRAFT.value or claim.state == import('sstreasury.models').ClaimState.RESUBMIT.value or (claim.state != import('sstreasury.models').ClaimState.APPROVED.value and claim.state != import('sstreasury.models').ClaimState.PAID.value and request.user.groups.filter(name='Treasury').exists()) %} + Edit + Print + {% elif claim.state == import('sstreasury.models').ClaimState.APPROVED.value or claim.state == import('sstreasury.models').ClaimState.PAID.value %} + Print + {% else %} + Print + +
+

This claim has been submitted and is now awaiting processing. If you wish to edit this claim, you must first withdraw it. This will revert the claim to a draft.

+
+ {% endif %} + + - + @@ -62,6 +87,10 @@ + + + + @@ -75,7 +104,19 @@
ID{{ claim.id }}RE-{{ claim.id }}
Purpose
Budget ID{{ claim.budget_id }}
Comments {{ claim.comments }}
- {# TODO #} +
+
+ +
+ + + + {% if claim.state == import('sstreasury.models').ClaimState.AWAIT_REVIEW.value and request.user.groups.filter(name='Treasury').exists() %} + + + + {% endif %} +
{% for item in history %} diff --git a/sstreasury/jinja2/sstreasury/email/claim_approved.md b/sstreasury/jinja2/sstreasury/email/claim_approved.md new file mode 100644 index 0000000..fd1da57 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/claim_approved.md @@ -0,0 +1,3 @@ +Your reimbursement claim titled *{{ claim.purpose }}* has been reviewed and approved by Treasury, and will be paid in the next pay cycle. + +{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }} diff --git a/sstreasury/jinja2/sstreasury/email/claim_commented.md b/sstreasury/jinja2/sstreasury/email/claim_commented.md new file mode 100644 index 0000000..3b5f67f --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/claim_commented.md @@ -0,0 +1,7 @@ +{{ comment.author.first_name }} {{ comment.author.last_name }} made a new comment on the reimbursement claim *{{ claim.purpose }}*: + +``` +{{ comment.content }} +``` + +{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }} diff --git a/sstreasury/jinja2/sstreasury/email/claim_returned.md b/sstreasury/jinja2/sstreasury/email/claim_returned.md new file mode 100644 index 0000000..17ee9c4 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/claim_returned.md @@ -0,0 +1,3 @@ +Your reimbursement claim titled *{{ claim.purpose }}* has been reviewed by Treasury and returned to you for re-drafting. You should make any requested changes and resubmit the claim. + +{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }} diff --git a/sstreasury/jinja2/sstreasury/email/claim_submitted_drafter.md b/sstreasury/jinja2/sstreasury/email/claim_submitted_drafter.md new file mode 100644 index 0000000..c3bd92c --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/claim_submitted_drafter.md @@ -0,0 +1,3 @@ +Your reimbursement claim titled *{{ claim.purpose }}* has been submitted for Treasury review. + +{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }} diff --git a/sstreasury/jinja2/sstreasury/email/claim_submitted_treasurer.md b/sstreasury/jinja2/sstreasury/email/claim_submitted_treasurer.md new file mode 100644 index 0000000..c404a97 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/email/claim_submitted_treasurer.md @@ -0,0 +1,3 @@ +A reimbursement claim titled *{{ claim.purpose }}* has been submitted for your review. + +{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }} diff --git a/sstreasury/models.py b/sstreasury/models.py index 87711da..48910ce 100644 --- a/sstreasury/models.py +++ b/sstreasury/models.py @@ -109,6 +109,7 @@ class ClaimAction(DescriptionEnum): class ReimbursementClaim(models.Model): purpose = models.CharField(max_length=100) date = models.DateField() + budget_id = models.CharField(max_length=20) comments = models.TextField() author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+') diff --git a/sstreasury/urls.py b/sstreasury/urls.py index 1a0114d..9583951 100644 --- a/sstreasury/urls.py +++ b/sstreasury/urls.py @@ -28,5 +28,8 @@ urlpatterns = [ path('claims/', views.claim_list, name='claim_list'), path('claims/new/', views.claim_new, name='claim_new'), path('claims/view/', views.claim_view, name='claim_view'), + path('claims/view//print', views.claim_print, name='claim_print'), + path('claims/edit/', views.claim_edit, name='claim_edit'), + path('claims/action/', views.claim_action, name='claim_action'), path('', views.index, name='treasury'), ] diff --git a/sstreasury/views.py b/sstreasury/views.py index aa8e457..064ad60 100644 --- a/sstreasury/views.py +++ b/sstreasury/views.py @@ -121,7 +121,6 @@ def revision_from_form(budget, revision, form): revision.date = form['date'] if form['date'] else None revision.comments = form['comments'] - revision.state = models.BudgetState.DRAFT.value revision.revenue = json.loads(form['revenue']) revision.revenue_comments = form['revenue_comments'] revision.expense = json.loads(form['expense']) @@ -154,6 +153,7 @@ def budget_new(request): revision.author = request.user revision.time = timezone.now() revision.action = models.BudgetAction.CREATE.value + revision.state = models.BudgetState.DRAFT.value revision = revision_from_form(budget, revision, request.POST) if request.POST['submit'] == 'Save': @@ -197,9 +197,8 @@ def budget_editable(viewfunc): if not request.user.groups.filter(name='Treasury').exists(): raise PermissionDenied - if request.user != revision.author and request.user not in revision.contributors.all(): - if not request.user.groups.filter(name='Treasury').exists(): - raise PermissionDenied + if not revision.can_view(request.user): + raise PermissionDenied return viewfunc(request, budget, revision) return func @@ -393,24 +392,10 @@ def claim_list(request): 'claims_closed': claims_closed }) -@login_required -def claim_view(request, id): - claim = models.ReimbursementClaim.objects.get(id=id) - - if not claim.can_view(request.user): - raise PermissionDenied - - history = list(itertools.chain(claim.claimhistory_set.all(), claim.claimcomment_set.all())) - history.sort(key=lambda x: x.time, reverse=True) - - return render(request, 'sstreasury/claim_view.html', { - 'claim': claim, - 'history': history - }) - def claim_from_form(claim, form): claim.purpose = form['purpose'] claim.date = form['date'] if form['date'] else None + claim.budget_id = form['budget_id'] claim.comments = form['comments'] claim.state = models.ClaimState.DRAFT.value @@ -428,6 +413,7 @@ def claim_new(request): claim.author = request.user claim.time = timezone.now() #revision.action = models.BudgetAction.CREATE.value + claim.state = models.BudgetState.DRAFT.value claim = claim_from_form(claim, request.POST) claim_history = models.ClaimHistory() @@ -450,3 +436,186 @@ def claim_new(request): return render(request, 'sstreasury/claim_edit.html', { 'claim': claim }) + +def uses_claim(viewfunc): + @functools.wraps(viewfunc) + def func(request, id): + claim = models.ReimbursementClaim.objects.get(id=id) + return viewfunc(request, claim) + return func + +def claim_viewable(viewfunc): + @functools.wraps(viewfunc) + def func(request, claim): + if not claim.can_view(request.user): + raise PermissionDenied + + return viewfunc(request, claim) + return func + +def claim_editable(viewfunc): + @functools.wraps(viewfunc) + def func(request, claim): + if claim.state == models.ClaimState.PAID.value: + raise PermissionDenied + + if claim.state != models.ClaimState.DRAFT.value and claim.state != models.ClaimState.RESUBMIT.value: + if not request.user.groups.filter(name='Treasury').exists(): + raise PermissionDenied + + if not claim.can_view(request.user): + raise PermissionDenied + + return viewfunc(request, claim) + return func + +@login_required +@uses_claim +@claim_viewable +def claim_view(request, claim): + history = list(itertools.chain(claim.claimhistory_set.all(), claim.claimcomment_set.all())) + history.sort(key=lambda x: x.time, reverse=True) + + return render(request, 'sstreasury/claim_view.html', { + 'claim': claim, + 'history': history + }) + +@login_required +@uses_claim +@claim_viewable +def claim_print(request, claim): + return render(request, 'sstreasury/claim_print.html', { + 'claim': claim + }) + +@login_required +@uses_claim +@claim_editable +def claim_edit(request, claim): + if request.method == 'POST': + if request.POST['submit'] == 'Delete': + claim.delete() + return redirect(reverse('claim_list')) + + with transaction.atomic(): + claim = claim_from_form(claim, request.POST) + + claim_history = models.ClaimHistory() + claim_history.claim = claim + claim_history.author = request.user + claim_history.state = claim.state + claim_history.time = timezone.now() + claim_history.action = models.ClaimAction.EDIT.value + claim_history.save() + + if request.POST['submit'] == 'Save': + return redirect(reverse('claim_view', kwargs={'id': claim.id})) + else: + return redirect(reverse('claim_edit', kwargs={'id': claim.id})) + else: + return render(request, 'sstreasury/claim_edit.html', { + 'claim': claim + }) + +@login_required +@uses_claim +@claim_editable +def claim_action(request, claim): + actions = request.POST['action'].split(',') + + if 'Comment' in actions and request.POST.get('comment', None): + with transaction.atomic(): + comment = models.ClaimComment() + comment.claim = claim + 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 reimbursement claim: {}'.format(claim.purpose), 'sstreasury/email/claim_commented.md', {'claim': claim, 'comment': comment}) + if comment.author != request.user: + emailer.send_mail([comment.author], 'New comment on reimbursement claim: {}'.format(revision.name), 'sstreasury/email/claim_commented.md', {'claim': claim, 'comment': comment}) + + if 'Submit' in actions: + if claim.state != models.ClaimState.DRAFT.value and claim.state != models.ClaimState.RESUBMIT.value: + raise PermissionDenied + + with transaction.atomic(): + claim.state = models.ClaimState.AWAIT_REVIEW.value + claim.save() + + claim_history = models.ClaimHistory() + claim_history.claim = claim + claim_history.author = request.user + claim_history.state = claim.state + claim_history.time = timezone.now() + claim_history.action = models.ClaimAction.UPDATE_STATE.value + claim_history.save() + + emailer = Emailer() + for user in User.objects.filter(groups__name='Treasury'): + emailer.send_mail([user.email], 'Action required: Reimbursement claim submitted: {}'.format(claim.purpose), 'sstreasury/email/claim_submitted_treasurer.md', {'claim': claim}) + emailer.send_mail([claim.author.email], 'Reimbursement claim submitted: {}'.format(claim.purpose), 'sstreasury/email/claim_submitted_drafter.md', {'claim': claim}) + + if 'Withdraw' in actions: + if claim.state == models.ClaimState.DRAFT.value or claim.state == models.ClaimState.RESUBMIT.value or claim.state == models.ClaimState.PAID.value: + raise PermissionDenied + + claim.state = models.ClaimState.DRAFT.value + claim.save() + + claim_history = models.ClaimHistory() + claim_history.claim = claim + claim_history.author = request.user + claim_history.state = claim.state + claim_history.time = timezone.now() + claim_history.action = models.ClaimAction.UPDATE_STATE.value + claim_history.save() + + if 'Approve' in actions: + if claim.state != models.ClaimState.AWAIT_REVIEW.value: + raise PermissionDenied + if not request.user.groups.filter(name='Treasury').exists(): + raise PermissionDenied + + with transaction.atomic(): + claim.state = models.ClaimState.APPROVED.value + claim.save() + + claim_history = models.ClaimHistory() + claim_history.claim = claim + claim_history.author = request.user + claim_history.state = claim.state + claim_history.time = timezone.now() + claim_history.action = models.ClaimAction.UPDATE_STATE.value + claim_history.save() + + emailer = Emailer() + emailer.send_mail([claim.author.email], 'Claim approved, awaiting payment: {}'.format(claim.purpose), 'sstreasury/email/claim_approved.md', {'claim': claim}) + + if 'Return' in actions: + if claim.state != models.ClaimState.AWAIT_REVIEW.value: + raise PermissionDenied + if not request.user.groups.filter(name='Treasury').exists(): + raise PermissionDenied + + with transaction.atomic(): + claim.state = models.ClaimState.RESUBMIT.value + claim.save() + + claim_history = models.ClaimHistory() + claim_history.claim = claim + claim_history.author = request.user + claim_history.state = claim.state + claim_history.time = timezone.now() + claim_history.action = models.ClaimAction.UPDATE_STATE.value + claim_history.save() + + emailer = Emailer() + emailer.send_mail([claim.author.email], 'Action required: Reimbursement claim returned for re-drafting: {}'.format(claim.purpose), 'sstreasury/email/claim_returned.md', {'claim': claim}) + + return redirect(reverse('claim_view', kwargs={'id': claim.id}))