From c4eb45514fcb3de14bf034a8c1f5768646c2ba92 Mon Sep 17 00:00:00 2001 From: Yingtong Li Date: Sun, 29 Dec 2019 00:30:30 +1100 Subject: [PATCH] Centralise access control logic --- sstreasury/jinja2/sstreasury/budget_view.html | 32 +- sstreasury/jinja2/sstreasury/claim_view.html | 25 +- sstreasury/models.py | 148 +++++++- sstreasury/views.py | 359 +++++++----------- 4 files changed, 303 insertions(+), 261 deletions(-) diff --git a/sstreasury/jinja2/sstreasury/budget_view.html b/sstreasury/jinja2/sstreasury/budget_view.html index 7334869..f8f7dad 100644 --- a/sstreasury/jinja2/sstreasury/budget_view.html +++ b/sstreasury/jinja2/sstreasury/budget_view.html @@ -27,36 +27,30 @@ Status: {{ revision.get_state_display() }} {% if is_latest %} - {% if revision.state == import('sstreasury.models').BudgetState.DRAFT.value or revision.state == import('sstreasury.models').BudgetState.RESUBMIT.value %} + {% if revision.can_submit(request.user) %} - - {#{% if request.user.groups.filter(name='Secretary').exists() %} - - - - {% endif %}#} - {% elif revision.state == import('sstreasury.models').BudgetState.AWAIT_REVIEW.value and request.user.groups.filter(name='Treasury').exists() %} + {% endif %} + {% if revision.can_endorse(request.user) %} - {% elif revision.state != import('sstreasury.models').BudgetState.APPROVED.value and request.user.groups.filter(name='Secretary').exists() %} + {% endif %} + {% if revision.can_approve(request.user) %} - {% elif revision.state == import('sstreasury.models').BudgetState.APPROVED.value %} - {# Blank #} - {% else %} + {% endif %} + {% if revision.can_withdraw(request.user) %} {% endif %} - {% if revision.state == import('sstreasury.models').BudgetState.DRAFT.value or revision.state == import('sstreasury.models').BudgetState.RESUBMIT.value or (revision.state != import('sstreasury.models').BudgetState.APPROVED.value and (request.user.groups.filter(name='Treasury').exists() or request.user.groups.filter(name='Secretary').exists())) %} + {% if revision.can_edit(request.user) %} Edit - Print - {% elif revision.state == import('sstreasury.models').BudgetState.APPROVED.value %} - Print - {% else %} - Print - + {% endif %} + + Print + + {% if not revision.can_edit(request.user) and revision.can_withdraw(request.user) %}

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

diff --git a/sstreasury/jinja2/sstreasury/claim_view.html b/sstreasury/jinja2/sstreasury/claim_view.html index d9c0fc0..7750da8 100644 --- a/sstreasury/jinja2/sstreasury/claim_view.html +++ b/sstreasury/jinja2/sstreasury/claim_view.html @@ -26,26 +26,25 @@
Status: {{ claim.get_state_display() }} - {% if claim.state == import('sstreasury.models').ClaimState.DRAFT.value or claim.state == import('sstreasury.models').ClaimState.RESUBMIT.value %} + {% if claim.can_submit(request.user) %} - {% elif claim.state == import('sstreasury.models').ClaimState.AWAIT_REVIEW.value and request.user.groups.filter(name='Treasury').exists() %} + {% endif %} + {% if claim.can_approve(request.user) %} - {% elif claim.state == import('sstreasury.models').ClaimState.APPROVED.value or claim.state == import('sstreasury.models').ClaimState.PAID.value %} - {# Blank #} - {% else %} + {% endif %} + {% if claim.can_withdraw(request.user) %} {% 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()) %} + {% if claim.can_edit(request.user) %} Edit - Print - {% elif claim.state == import('sstreasury.models').ClaimState.APPROVED.value or claim.state == import('sstreasury.models').ClaimState.PAID.value %} - Print - {% else %} - Print - + {% endif %} + + Print + + {% if not claim.can_edit(request.user) and claim.can_withdraw(request.user) %}

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.

@@ -111,7 +110,7 @@ - {% if claim.state == import('sstreasury.models').ClaimState.AWAIT_REVIEW.value and request.user.groups.filter(name='Treasury').exists() %} + {% if claim.can_approve(request.user) %} diff --git a/sstreasury/models.py b/sstreasury/models.py index 48910ce..619ea2f 100644 --- a/sstreasury/models.py +++ b/sstreasury/models.py @@ -17,6 +17,7 @@ from django.contrib.auth.models import User from django.db import models +from django.utils import timezone from jsonfield import JSONField from enum import Enum @@ -74,12 +75,25 @@ class BudgetRevision(models.Model): action = models.IntegerField(choices=[(v.value, v.description) for v in BudgetAction]) + class Meta: + ordering = ['id'] + def copy(self): contributors = list(self.contributors.all()) self.pk, self.id = None, None self.save() self.contributors.add(*contributors) + def update_state(self, user, state): + self.copy() + self.author = user + self.time = timezone.now() + self.state = state.value + self.action = BudgetAction.UPDATE_STATE.value + self.save() + + # Access control + def can_view(self, user): if user == self.author: return True @@ -87,12 +101,74 @@ class BudgetRevision(models.Model): return True if user.groups.filter(name='Treasury').exists(): return True - if (self.state == BudgetState.ENDORSED or self.state == BudgetState.APPROVED) and user.groups.filter(name='Committee').exists(): + if (self.state == BudgetState.ENDORSED.value or self.state == BudgetState.APPROVED.value) and user.groups.filter(name='Committee').exists(): + return True + + return False + + def can_edit(self, user): + # Cannot edit if cannot view + if not self.can_view(user): + return False + + # No one can edit if already approved + if self.state == BudgetState.APPROVED.value: + return False + + # Only Treasurer or Secretary may edit if submitted + if self.state != BudgetState.DRAFT.value and self.state != BudgetState.RESUBMIT.value: + if user.groups.filter(name='Treasury').exists() or user.groups.filter(name='Secretary').exists(): + return True + return False + + # Otherwise the submitter may edit + if user == self.author: + return True + if user in self.contributors.all(): + return True + if user.groups.filter(name='Treasury').exists(): + return True + + # Otherwise cannot edit + return False + + def can_submit(self, user): + if not self.can_edit(user): + return False + if self.state == BudgetState.DRAFT.value or self.state == BudgetState.RESUBMIT.value: return True return False - class Meta: - ordering = ['id'] + def can_withdraw(self, user): + if not self.can_edit(user): + return False + if self.state == BudgetState.AWAIT_REVIEW.value or self.state == BudgetState.ENDORSED.value: + return True + return False + + def can_endorse(self, user): + if not self.can_edit(user): + return False + if not user.groups.filter(name='Treasury').exists(): + return False + if self.state == BudgetState.AWAIT_REVIEW.value: + return True + return False + + def can_return(self, user): + return self.can_endorse(user) + + def can_approve(self, user): + if not self.can_edit(user): + return False + if not user.groups.filter(name='Secretary').exists(): + return False + if self.state == BudgetState.ENDORSED.value: + return True + return False + + def can_cmtereturn(self, user): + return self.can_approve(user) class ClaimState(DescriptionEnum): DRAFT = 10, 'Draft' @@ -119,12 +195,78 @@ class ReimbursementClaim(models.Model): items = JSONField(default=[]) + def update_state(self, user, state): + self.state = state.value + self.save() + + claim_history = ClaimHistory() + claim_history.claim = self + claim_history.author = user + claim_history.state = self.state + claim_history.time = timezone.now() + claim_history.action = ClaimAction.UPDATE_STATE.value + claim_history.save() + + # Access control + def can_view(self, user): if user == self.author: return True if user.groups.filter(name='Treasury').exists(): return True return False + + def can_edit(self, user): + # Cannot edit if cannot view + if not self.can_view(user): + return False + + # No one can edit if already paid + if self.state == ClaimState.PAID.value: + return False + + # Only Treasurer may edit if submitted + if self.state != ClaimState.DRAFT.value and self.state != ClaimState.RESUBMIT.value: + if user.groups.filter(name='Treasury').exists(): + return True + return False + + # Otherwise the submitter or Treasurer may edit + if user == self.author: + return True + if user in self.contributors.all(): + return True + if user.groups.filter(name='Treasury').exists(): + return True + + # Otherwise cannot edit + return False + + def can_submit(self, user): + if not self.can_edit(user): + return False + if self.state == ClaimState.DRAFT.value or self.state == ClaimState.RESUBMIT.value: + return True + return False + + def can_withdraw(self, user): + if not self.can_edit(user): + return False + if self.state == ClaimState.AWAIT_REVIEW.value or self.state == ClaimState.APPROVED.value: + return True + return False + + def can_approve(self, user): + if not self.can_edit(user): + return False + if not user.groups.filter(name='Treasury').exists(): + return False + if self.state == ClaimState.AWAIT_REVIEW.value: + return True + return False + + def can_return(self, user): + return self.can_approve(user) class ClaimReceipt(models.Model): claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE) diff --git a/sstreasury/views.py b/sstreasury/views.py index 064ad60..3ac00f9 100644 --- a/sstreasury/views.py +++ b/sstreasury/views.py @@ -32,10 +32,107 @@ import functools import itertools import json +# HELPER DECORATORS + +def uses_budget(viewfunc): + @functools.wraps(viewfunc) + def func(request, id): + budget = models.Budget.objects.get(id=id) + revision = budget.budgetrevision_set.reverse()[0] + return viewfunc(request, budget, revision) + return func + +def budget_viewable(viewfunc): + @functools.wraps(viewfunc) + def func(request, budget, revision): + if not revision.can_view(request.user): + raise PermissionDenied + return viewfunc(request, budget, revision) + return func + +def budget_editable(viewfunc): + @functools.wraps(viewfunc) + def func(request, budget, revision): + if not revision.can_edit(request.user): + raise PermissionDenied + return viewfunc(request, budget, revision) + return func + +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 not claim.can_edit(request.user): + raise PermissionDenied + return viewfunc(request, claim) + return func + +# HELPER FUNCTIONS + +def revision_from_form(budget, revision, form): + revision.budget = budget + + revision.name = form['name'] + revision.date = form['date'] if form['date'] else None + + revision.comments = form['comments'] + revision.revenue = json.loads(form['revenue']) + revision.revenue_comments = form['revenue_comments'] + revision.expense = json.loads(form['expense']) + revision.expense_comments = form['expense_comments'] + revision.expense_no_emergency_fund = True if form.get('expense_no_emergency_fund', False) else False + + revision.save() + + contributors = form['contributors'].split('\n') + for contributor in contributors: + validate_email(contributor.strip()) + for contributor in contributors: + try: + user = User.objects.get(email=contributor.strip()) + except User.DoesNotExist: + user = User.objects.create_user(contributor.strip().split('@')[0], contributor.strip()) + revision.contributors.add(user) + + revision.save() + + return revision + +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 + claim.items = json.loads(form['items']) + + claim.save() + + return claim + +# INDEX VIEW + @login_required def index(request): return render(request, 'sstreasury/index.html') +# BUDGET VIEWS + @login_required def budget_list(request): budgets_action = [] @@ -77,13 +174,10 @@ def budget_list(request): }) @login_required -def budget_view(request, id): - budget = models.Budget.objects.get(id=id) - +@uses_budget +def budget_view(request, budget, revision): if 'revision' in request.GET: revision = budget.budgetrevision_set.get(id=int(request.GET['revision'])) - else: - revision = budget.budgetrevision_set.reverse()[0] if not revision.can_view(request.user): raise PermissionDenied @@ -98,13 +192,10 @@ def budget_view(request, id): }) @login_required -def budget_print(request, id): - budget = models.Budget.objects.get(id=id) - +@uses_budget +def budget_print(request, budget, revision): if 'revision' in request.GET: revision = budget.budgetrevision_set.get(id=int(request.GET['revision'])) - else: - revision = budget.budgetrevision_set.reverse()[0] if not revision.can_view(request.user): raise PermissionDenied @@ -114,35 +205,6 @@ def budget_print(request, id): 'is_latest': 'revision' not in request.GET }) -def revision_from_form(budget, revision, form): - revision.budget = budget - - revision.name = form['name'] - revision.date = form['date'] if form['date'] else None - - revision.comments = form['comments'] - revision.revenue = json.loads(form['revenue']) - revision.revenue_comments = form['revenue_comments'] - revision.expense = json.loads(form['expense']) - revision.expense_comments = form['expense_comments'] - revision.expense_no_emergency_fund = True if form.get('expense_no_emergency_fund', False) else False - - revision.save() - - contributors = form['contributors'].split('\n') - for contributor in contributors: - validate_email(contributor.strip()) - for contributor in contributors: - try: - user = User.objects.get(email=contributor.strip()) - except User.DoesNotExist: - user = User.objects.create_user(contributor.strip().split('@')[0], contributor.strip()) - revision.contributors.add(user) - - revision.save() - - return revision - @login_required def budget_new(request): if request.method == 'POST': @@ -170,39 +232,6 @@ def budget_new(request): 'contributors': request.user.email }) -def uses_budget(viewfunc): - @functools.wraps(viewfunc) - def func(request, id): - budget = models.Budget.objects.get(id=id) - revision = budget.budgetrevision_set.reverse()[0] - return viewfunc(request, budget, revision) - return func - -def budget_viewable(viewfunc): - @functools.wraps(viewfunc) - def func(request, budget, revision): - if not revision.can_view(request.user): - raise PermissionDenied - - return viewfunc(request, budget, revision) - return func - -def budget_editable(viewfunc): - @functools.wraps(viewfunc) - def func(request, budget, revision): - if revision.state == models.BudgetState.APPROVED.value: - raise PermissionDenied - - if revision.state != models.BudgetState.DRAFT.value and revision.state != models.BudgetState.RESUBMIT.value: - 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 - @login_required @uses_budget @budget_editable @@ -253,16 +282,11 @@ def budget_action(request, budget, revision): emailer.send_mail([user.email], 'New comment on budget: {}'.format(revision.name), 'sstreasury/email/commented.md', {'revision': revision, 'comment': comment}) if 'Submit' in actions: - if revision.state != models.BudgetState.DRAFT.value and revision.state != models.BudgetState.RESUBMIT.value: + if not revision.can_submit(request.user): raise PermissionDenied with transaction.atomic(): - 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() + revision.update_state(request.user, models.BudgetState.AWAIT_REVIEW) emailer = Emailer() for user in User.objects.filter(groups__name='Treasury'): @@ -271,29 +295,18 @@ def budget_action(request, budget, revision): emailer.send_mail([user.email], 'Budget submitted: {}'.format(revision.name), 'sstreasury/email/submitted_drafter.md', {'revision': revision}) if 'Withdraw' in actions: - if revision.state == models.BudgetState.DRAFT.value or revision.state == models.BudgetState.RESUBMIT.value or revision.state == models.BudgetState.APPROVED.value: - raise PermissionDenied - - 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() - - if 'Endorse' in actions: - if revision.state != models.BudgetState.AWAIT_REVIEW.value: - raise PermissionDenied - if not request.user.groups.filter(name='Treasury').exists(): + if not revision.can_withdraw(user): raise PermissionDenied with transaction.atomic(): - revision.copy() - revision.author = request.user - revision.time = timezone.now() - revision.state = models.BudgetState.ENDORSED.value - revision.action = models.BudgetAction.UPDATE_STATE.value - revision.save() + revision.update_state(request.user, models.BudgetState.DRAFT) + + if 'Endorse' in actions: + if not revision.can_endorse(user): + raise PermissionDenied + + with transaction.atomic(): + revision.update_state(request.user, models.BudgetState.ENDORSED) emailer = Emailer() for user in User.objects.filter(groups__name='Secretary'): @@ -302,54 +315,33 @@ def budget_action(request, budget, revision): emailer.send_mail([user.email], 'Budget endorsed, awaiting committee approval: {}'.format(revision.name), 'sstreasury/email/endorsed.md', {'revision': revision}) if 'Return' in actions: - if revision.state != models.BudgetState.AWAIT_REVIEW.value: - raise PermissionDenied - if not request.user.groups.filter(name='Treasury').exists(): + if not revision.can_return(user): raise PermissionDenied with transaction.atomic(): - revision.copy() - revision.author = request.user - revision.time = timezone.now() - revision.state = models.BudgetState.RESUBMIT.value - revision.action = models.BudgetAction.UPDATE_STATE.value - revision.save() + revision.update_state(request.user, models.BudgetState.RESUBMIT) emailer = Emailer() for user in revision.contributors.all(): emailer.send_mail([user.email], 'Action required: Budget returned for re-drafting: {}'.format(revision.name), 'sstreasury/email/returned.md', {'revision': revision}) if 'Approve' in actions: - if revision.state == models.BudgetState.APPROVED.value: - raise PermissionDenied - if not request.user.groups.filter(name='Secretary').exists(): - raise PermissionDenied + if not revision.can_approve(user): + return PermissionDenied with transaction.atomic(): - revision.copy() - revision.author = request.user - revision.time = timezone.now() - revision.state = models.BudgetState.APPROVED.value - revision.action = models.BudgetAction.UPDATE_STATE.value - revision.save() + revision.update_state(request.user, models.BudgetState.APPROVED) emailer = Emailer() for user in revision.contributors.all(): emailer.send_mail([user.email], 'Budget approved: {}'.format(revision.name), 'sstreasury/email/approved.md', {'revision': revision}) if 'CmteReturn' in actions: - if revision.state == models.BudgetState.APPROVED.value: - raise PermissionDenied - if not request.user.groups.filter(name='Secretary').exists(): - raise PermissionDenied + if not revision.can_cmtereturn(user): + return PermissionDenied with transaction.atomic(): - revision.copy() - revision.author = request.user - revision.time = timezone.now() - revision.state = models.BudgetState.RESUBMIT.value - revision.action = models.BudgetAction.UPDATE_STATE.value - revision.save() + revision.update_state(request.user, models.BudgetState.RESUBMIT) emailer = Emailer() for user in revision.contributors.all(): @@ -392,19 +384,6 @@ def claim_list(request): 'claims_closed': claims_closed }) -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 - claim.items = json.loads(form['items']) - - claim.save() - - return claim - @login_required def claim_new(request): if request.method == 'POST': @@ -412,7 +391,6 @@ def claim_new(request): claim = models.ReimbursementClaim() 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) @@ -437,38 +415,6 @@ def claim_new(request): '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 @@ -541,20 +487,11 @@ def claim_action(request, claim): 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: + if not claim.can_submit(request.user): 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() + claim.update_state(request.user, models.ClaimState.AWAIT_REVIEW) emailer = Emailer() for user in User.objects.filter(groups__name='Treasury'): @@ -562,58 +499,28 @@ def claim_action(request, 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(): + if not claim.can_withdraw(request.user): 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() + claim.update_state(request.user, models.ClaimState.DRAFT) + + if 'Approve' in actions: + if not claim.can_approve(request.user): + raise PermissionDenied + + with transaction.atomic(): + claim.update_state(request.user, models.ClaimState.APPROVED) 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(): + if not claim.can_approve(request.user): 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() + claim.update_state(request.user, models.ClaimState.RESUBMIT) 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})