From f3287e127a26cfd6bd38b6e5148a664f13366e2d Mon Sep 17 00:00:00 2001 From: Yingtong Li Date: Sat, 28 Dec 2019 19:11:22 +1100 Subject: [PATCH] Basic reimbursement claim data model and interface --- sstreasury/jinja2/sstreasury/base.html | 2 +- sstreasury/jinja2/sstreasury/claim_edit.html | 168 ++++++++++++++++++ sstreasury/jinja2/sstreasury/claim_list.html | 74 ++++++++ sstreasury/jinja2/sstreasury/claim_view.html | 170 +++++++++++++++++++ sstreasury/models.py | 85 ++++++++-- sstreasury/static/sstreasury/budget.js | 2 +- sstreasury/static/sstreasury/claim.js | 88 ++++++++++ sstreasury/urls.py | 3 + sstreasury/views.py | 136 ++++++++++++--- 9 files changed, 689 insertions(+), 39 deletions(-) create mode 100644 sstreasury/jinja2/sstreasury/claim_edit.html create mode 100644 sstreasury/jinja2/sstreasury/claim_list.html create mode 100644 sstreasury/jinja2/sstreasury/claim_view.html create mode 100644 sstreasury/static/sstreasury/claim.js diff --git a/sstreasury/jinja2/sstreasury/base.html b/sstreasury/jinja2/sstreasury/base.html index 81eee21..83b3b36 100644 --- a/sstreasury/jinja2/sstreasury/base.html +++ b/sstreasury/jinja2/sstreasury/base.html @@ -33,7 +33,7 @@
Reimbursements
diff --git a/sstreasury/jinja2/sstreasury/claim_edit.html b/sstreasury/jinja2/sstreasury/claim_edit.html new file mode 100644 index 0000000..352e762 --- /dev/null +++ b/sstreasury/jinja2/sstreasury/claim_edit.html @@ -0,0 +1,168 @@ +{% extends 'sstreasury/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 %}{% if request.resolver_match.url_name == 'claim_new' %}New{% else %}Edit{% endif %} reimbursement claim{% endblock %} + +{% block maincontent %} +

{% if request.resolver_match.url_name == 'claim_new' %}New{% else %}Edit{% endif %} reimbursement claim

+ +
+
+ + +
+
+ + +
+
+ +
+
+
+ + +
+
+
+
+ +
+
+ + +
+
+
+ +
+
+
+ +
+
+ + + + {% if request.resolver_match.url_name == 'claim_edit' %} + + {% endif %} +
+{% endblock %} + +{% block head %} + {{ super() }} + + + + + +{% endblock %} + +{% block script %} + {{ super() }} + + + + + + + + +{% endblock %} diff --git a/sstreasury/jinja2/sstreasury/claim_list.html b/sstreasury/jinja2/sstreasury/claim_list.html new file mode 100644 index 0000000..92ff02c --- /dev/null +++ b/sstreasury/jinja2/sstreasury/claim_list.html @@ -0,0 +1,74 @@ +{% extends 'sstreasury/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 %}Your reimbursement claims{% endblock %} + +{% macro listclaims(claims) %} + + + + + + + + + + {% for claim in claims %} + + + + + + {% endfor %} + +
NameStatusView
{{ claim.purpose }}{{ claim.get_state_display() }} + +
+{% endmacro %} + +{% block maincontent %} +

Your reimbursement claims

+ + {% if not claims_action and not claims_open and not claims_closed %} +

You have no reimbursement claims to view. To create a claim, click Create new claim.

+ {% endif %} + + {% if claims_action %} +

Claims requiring action

+ + {{ listclaims(claims_action) }} + {% endif %} + + {% if claims_open %} +

Open claims

+ + {{ listclaims(claims_open) }} + {% endif %} + + {% if claims_closed %} +

Closed claims

+ + {{ listclaims(claims_closed) }} + {% endif %} +{% endblock %} + +{% block head %} + {{ super() }} +{% endblock %} diff --git a/sstreasury/jinja2/sstreasury/claim_view.html b/sstreasury/jinja2/sstreasury/claim_view.html new file mode 100644 index 0000000..999474d --- /dev/null +++ b/sstreasury/jinja2/sstreasury/claim_view.html @@ -0,0 +1,170 @@ +{% extends 'sstreasury/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 maincontent %} +

{{ claim.purpose }}

+ +
+ Status: {{ claim.get_state_display() }} + + {# TODO #} +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + +
ID{{ claim.id }}
Purpose{{ claim.purpose }}
Expenditure date{{ claim.date }}
Claimee + +
Comments{{ claim.comments }}
Items +
+
+ + {# TODO #} + +
+ {% for item in history %} + {% if item.__class__.__name__ == 'ClaimComment' %} +
+
+ +
+
+
+ + {{ item.author.first_name }} {{ item.author.last_name }} commented +
+ {{ localtime(item.time) }} +
+
+
+ {{ item.content|markdown }} +
+
+
+ {% elif item.__class__.__name__ == 'ClaimHistory' %} +
+
+ +
+
+
+ + {% if item.action == import('sstreasury.models').ClaimAction.CREATE.value %} + {{ item.author.first_name }} {{ item.author.last_name }} created the claim + {% elif item.action == import('sstreasury.models').ClaimAction.EDIT.value %} + {{ item.author.first_name }} {{ item.author.last_name }} edited the claim + {% elif item.action == import('sstreasury.models').ClaimAction.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 claim + {% endif %} +
+ {{ localtime(item.time) }} +
+
+
+
+ {% endif %} + {% endfor %} +
+{% endblock %} + +{% block head %} + {{ super() }} + + + + + +{% endblock %} + +{% block script %} + {{ super() }} + + + + + + +{% endblock %} diff --git a/sstreasury/models.py b/sstreasury/models.py index 429dc5c..87711da 100644 --- a/sstreasury/models.py +++ b/sstreasury/models.py @@ -21,6 +21,13 @@ from jsonfield import JSONField from enum import Enum +class DescriptionEnum(Enum): + def __new__(cls, value, description): + obj = object.__new__(cls) + obj._value_ = value + obj.description = description + return obj + class Budget(models.Model): pass @@ -33,30 +40,18 @@ class BudgetComment(models.Model): class Meta: ordering = ['id'] -class BudgetState(Enum): +class BudgetState(DescriptionEnum): DRAFT = 10, 'Draft' RESUBMIT = 20, 'Returned for redrafting' AWAIT_REVIEW = 30, 'Awaiting Treasury review' ENDORSED = 40, 'Endorsed by Treasury, awaiting committee approval' APPROVED = 50, 'Approved' #CANCELLED = 60, 'Cancelled' - - def __new__(cls, value, description): - obj = object.__new__(cls) - obj._value_ = value - obj.description = description - return obj -class BudgetAction(Enum): +class BudgetAction(DescriptionEnum): CREATE = 5, 'Created' EDIT = 10, 'Edited' UPDATE_STATE = 20, 'Updated state' - - def __new__(cls, value, description): - obj = object.__new__(cls) - obj._value_ = value - obj.description = description - return obj class BudgetRevision(models.Model): budget = models.ForeignKey(Budget, on_delete=models.CASCADE) @@ -85,5 +80,67 @@ class BudgetRevision(models.Model): self.save() self.contributors.add(*contributors) + def can_view(self, user): + if user == self.author: + return True + if user in self.contributors.all(): + 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(): + return True + return False + class Meta: ordering = ['id'] + +class ClaimState(DescriptionEnum): + DRAFT = 10, 'Draft' + RESUBMIT = 20, 'Returned for redrafting' + AWAIT_REVIEW = 30, 'Awaiting Treasury approval' + APPROVED = 40, 'Approved by Treasury, awaiting payment' + PAID = 50, 'Paid' + +class ClaimAction(DescriptionEnum): + CREATE = 5, 'Created' + EDIT = 10, 'Edited' + UPDATE_STATE = 20, 'Updated state' + +class ReimbursementClaim(models.Model): + purpose = models.CharField(max_length=100) + date = models.DateField() + comments = models.TextField() + + author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+') + time = models.DateTimeField() + + state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState]) + + items = JSONField(default=[]) + + def can_view(self, user): + if user == self.author: + return True + if user.groups.filter(name='Treasury').exists(): + return True + return False + +class ClaimReceipt(models.Model): + claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE) + uploaded_file = models.FileField() + +class ClaimComment(models.Model): + claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE) + author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+') + time = models.DateTimeField() + content = models.TextField() + + class Meta: + ordering = ['id'] + +class ClaimHistory(models.Model): + claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE) + author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+') + state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState]) + time = models.DateTimeField() + action = models.IntegerField(choices=[(v.value, v.description) for v in ClaimAction]) diff --git a/sstreasury/static/sstreasury/budget.js b/sstreasury/static/sstreasury/budget.js index 2807eca..a63324d 100644 --- a/sstreasury/static/sstreasury/budget.js +++ b/sstreasury/static/sstreasury/budget.js @@ -135,7 +135,7 @@ function makeGrid() { height: editing ? '20em' : 'auto', inserting: editing, editing: editing, - noDataContent: editing ? 'No entries. Click the green plus icon at the top right to add a new row.' : 'No entries', + noDataContent: editing ? 'No entries. Enter details above then click the green plus icon.' : 'No entries', data: expense_data, fields: f, onItemUpdated: recalcExpTotal, diff --git a/sstreasury/static/sstreasury/claim.js b/sstreasury/static/sstreasury/claim.js new file mode 100644 index 0000000..9f2a299 --- /dev/null +++ b/sstreasury/static/sstreasury/claim.js @@ -0,0 +1,88 @@ +/* + 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 . +*/ + +function recalcTotal(args) { + var total = 0; + var gst = 0; + for (var row of args.grid.data) { + total += row['Unit price\n(incl GST)'] * row['Units']; + if (!row['GST-free']) { + gst += (row['Unit price\n(incl GST)'] * row['Units']) / 11; + } + } + + $(args.grid._body).find('.totalrow').remove(); + + var totalrow = $(''); + totalrow.append($('Includes GST of:').prop('colspan', args.grid.fields.length - (editing ? 2 : 1))); + totalrow.append($('').text('$' + gst.toFixed(2))); + if (editing) { + totalrow.append($('')); + } + $(args.grid._body).find('tr:last').after(totalrow); + + var totalrow = $(''); + totalrow.append($('Total:').prop('colspan', args.grid.fields.length - (editing ? 2 : 1))); + totalrow.append($('').text('$' + total.toFixed(2))); + if (editing) { + totalrow.append($('')); + } + $(args.grid._body).find('tr:last').after(totalrow); +} + +// Allow floats +function FloatNumberField(config) { + jsGrid.NumberField.call(this, config); +} +FloatNumberField.prototype = new jsGrid.NumberField({ + filterValue: function() { + return parseFloat(this.filterControl.val()); + }, + insertValue: function() { + return parseFloat(this.insertControl.val()); + }, + editValue: function() { + return parseFloat(this.editControl.val()); + } +}); +jsGrid.fields.float = FloatNumberField; + +function makeGrid() { + f = [ + { name: 'Description', type: 'text', width: '50%', validate: 'required' }, + { name: 'Unit price\n(incl GST)', type: 'float', width: '12.5%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } }, + { name: 'Units', type: 'float', width: '12.5%', validate: 'required' }, + { name: 'GST-free', type: 'checkbox', width: '5%' }, + { name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit price\n(incl GST)'] * item['Units']).toFixed(2); } }, + ]; + if (editing) { + f.push({ type: 'control', width: '10%', modeSwitchButton: false }); + } + + $('#items_grid').jsGrid({ + width: '100%', + height: editing ? '20em' : 'auto', + inserting: editing, + editing: editing, + noDataContent: editing ? 'No entries. Enter details above then click the green plus icon.' : 'No entries', + data: items_data, + fields: f, + onItemUpdated: recalcTotal, + onRefreshed: recalcTotal, + }); +} diff --git a/sstreasury/urls.py b/sstreasury/urls.py index 4165d72..1a0114d 100644 --- a/sstreasury/urls.py +++ b/sstreasury/urls.py @@ -25,5 +25,8 @@ urlpatterns = [ path('budgets/view//print', views.budget_print, name='budget_print'), path('budgets/edit/', views.budget_edit, name='budget_edit'), path('budgets/action/', views.budget_action, name='budget_action'), + 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('', views.index, name='treasury'), ] diff --git a/sstreasury/views.py b/sstreasury/views.py index ae4c7b1..aa8e457 100644 --- a/sstreasury/views.py +++ b/sstreasury/views.py @@ -52,20 +52,20 @@ def budget_list(request): group = budgets_action elif request.user.groups.filter(name='Secretary').exists() and state == models.BudgetState.ENDORSED: group = budgets_action - else: - if request.user in revision.contributors.all(): - if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]: - group = budgets_action - elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]: - group = budgets_open - else: - group = budgets_closed + elif request.user.groups.filter(name='Committee').exists() and state == models.BudgetState.ENDORSED: + group = budgets_action + elif request.user == revision.author or request.user in revision.contributors.all(): + if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]: + group = budgets_action + elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]: + group = budgets_open else: - if request.user in revision.contributors.all() or request.user.groups.filter(name='Treasury').exists() or request.user.groups.filter(name='Secretary').exists(): - if state == models.BudgetState.APPROVED: - group = budgets_closed - else: - group = budgets_open + group = budgets_closed + elif request.user.groups.filter(name='Treasury').exists() or request.user.groups.filter(name='Secretary').exists(): + if state == models.BudgetState.APPROVED: + group = budgets_closed + else: + group = budgets_open if group is not None: group.append(revision) @@ -85,9 +85,8 @@ def budget_view(request, id): else: revision = budget.budgetrevision_set.reverse()[0] - if 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 history = list(itertools.chain(budget.budgetrevision_set.all(), revision.budget.budgetcomment_set.all())) history.sort(key=lambda x: x.time, reverse=True) @@ -107,9 +106,8 @@ def budget_print(request, id): else: revision = budget.budgetrevision_set.reverse()[0] - if 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 render(request, 'sstreasury/budget_print.html', { 'revision': revision, @@ -183,9 +181,8 @@ def uses_budget(viewfunc): def budget_viewable(viewfunc): @functools.wraps(viewfunc) def func(request, budget, revision): - if 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 @@ -200,7 +197,7 @@ def budget_editable(viewfunc): if not request.user.groups.filter(name='Treasury').exists(): raise PermissionDenied - if request.user not in revision.contributors.all(): + if request.user != revision.author and request.user not in revision.contributors.all(): if not request.user.groups.filter(name='Treasury').exists(): raise PermissionDenied @@ -360,3 +357,96 @@ def budget_action(request, budget, revision): emailer.send_mail([user.email], 'Action required: Budget returned for re-drafting: {}'.format(revision.name), 'sstreasury/email/returned_committee.md', {'revision': revision}) return redirect(reverse('budget_view', kwargs={'id': budget.id})) + +@login_required +def claim_list(request): + claims_action = [] + claims_open = [] + claims_closed = [] + + for claim in models.ReimbursementClaim.objects.all(): + state = models.ClaimState(claim.state) + + group = None + + if request.user.groups.filter(name='Treasury').exists() and state in [models.ClaimState.AWAIT_REVIEW, models.ClaimState.APPROVED]: + group = claims_action + elif request.user == claim.author: + if state in [models.ClaimState.DRAFT, models.ClaimState.RESUBMIT]: + group = claims_action + elif state in [models.ClaimState.AWAIT_REVIEW, models.ClaimState.APPROVED]: + group = claims_open + else: + group = claims_closed + elif request.user.groups.filter(name='Treasury').exists(): + if state == models.ClaimState.APPROVED: + group = claims_closed + else: + group = claims_open + + if group is not None: + group.append(claim) + + return render(request, 'sstreasury/claim_list.html', { + 'claims_action': claims_action, + 'claims_open': claims_open, + '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.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': + with transaction.atomic(): + claim = models.ReimbursementClaim() + claim.author = request.user + claim.time = timezone.now() + #revision.action = models.BudgetAction.CREATE.value + 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.CREATE.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})) + pass + else: + claim = models.ReimbursementClaim() + claim.author = request.user + + return render(request, 'sstreasury/claim_edit.html', { + 'claim': claim + })