# 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 . from django.contrib.auth.decorators import login_required from django.contrib.auth.models import User from django.core.exceptions import PermissionDenied from django.core.validators import validate_email from django.db import transaction from django.shortcuts import render, redirect from django.urls import reverse from django.utils import timezone from django.views import generic from . import models from ssmain.email import Emailer 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 = [] budgets_open = [] budgets_closed = [] for budget in models.Budget.objects.all(): revision = budget.budgetrevision_set.reverse()[0] state = models.BudgetState(revision.state) group = None if request.user.groups.filter(name='Treasury').exists() and state == models.BudgetState.AWAIT_REVIEW: group = budgets_action elif request.user.groups.filter(name='Secretary').exists() and state == models.BudgetState.ENDORSED: group = budgets_action 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: 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) return render(request, 'sstreasury/budget_list.html', { 'budgets_action': budgets_action, 'budgets_open': budgets_open, 'budgets_closed': budgets_closed }) @login_required @uses_budget def budget_view(request, budget, revision): if 'revision' in request.GET: revision = budget.budgetrevision_set.get(id=int(request.GET['revision'])) 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) return render(request, 'sstreasury/budget_view.html', { 'revision': revision, 'history': history, 'is_latest': 'revision' not in request.GET }) @login_required @uses_budget def budget_print(request, budget, revision): if 'revision' in request.GET: revision = budget.budgetrevision_set.get(id=int(request.GET['revision'])) if not revision.can_view(request.user): raise PermissionDenied return render(request, 'sstreasury/budget_print.html', { 'revision': revision, 'is_latest': 'revision' not in request.GET }) @login_required def budget_new(request): if request.method == 'POST': with transaction.atomic(): budget = models.Budget() budget.save() revision = models.BudgetRevision() 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': return redirect(reverse('budget_view', kwargs={'id': budget.id})) else: return redirect(reverse('budget_edit', kwargs={'id': budget.id})) else: budget = models.Budget() revision = models.BudgetRevision() revision.budget = budget return render(request, 'sstreasury/budget_edit.html', { 'revision': revision, 'contributors': request.user.email }) @login_required @uses_budget @budget_editable def budget_edit(request, budget, revision): if request.method == 'POST': if request.POST['submit'] == 'Delete': budget.delete() return redirect(reverse('budget_list')) with transaction.atomic(): 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': return redirect(reverse('budget_view', kwargs={'id': budget.id})) else: return redirect(reverse('budget_edit', kwargs={'id': budget.id})) else: return render(request, 'sstreasury/budget_edit.html', { 'revision': revision, 'contributors': '\n'.join(revision.contributors.all().values_list('email', flat=True)) }) @login_required @uses_budget @budget_editable def budget_action(request, budget, revision): actions = request.POST['action'].split(',') if 'Comment' in actions and request.POST.get('comment', None): 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/budget_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/budget_commented.md', {'revision': revision, 'comment': comment}) if 'Submit' in actions: if not revision.can_submit(request.user): raise PermissionDenied with transaction.atomic(): revision.update_state(request.user, models.BudgetState.AWAIT_REVIEW) emailer = Emailer() for user in User.objects.filter(groups__name='Treasury'): emailer.send_mail([user.email], 'Action required: Budget submitted: {}'.format(revision.name), 'sstreasury/email/budget_submitted_treasurer.md', {'revision': revision}) for user in revision.contributors.all(): emailer.send_mail([user.email], 'Budget submitted: {}'.format(revision.name), 'sstreasury/email/budget_submitted_drafter.md', {'revision': revision}) if 'Withdraw' in actions: if not revision.can_withdraw(user): raise PermissionDenied with transaction.atomic(): 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'): emailer.send_mail([user.email], 'Action required: Budget endorsed: {}'.format(revision.name), 'sstreasury/email/budget_endorsed_secretary.md', {'revision': revision}) for user in revision.contributors.all(): emailer.send_mail([user.email], 'Budget endorsed, awaiting committee approval: {}'.format(revision.name), 'sstreasury/email/budget_endorsed_drafter.md', {'revision': revision}) if 'Return' in actions: if not revision.can_return(user): raise PermissionDenied with transaction.atomic(): 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/budget_returned.md', {'revision': revision}) if 'Approve' in actions: if not revision.can_approve(user): return PermissionDenied with transaction.atomic(): 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/budget_approved.md', {'revision': revision}) if 'CmteReturn' in actions: if not revision.can_cmtereturn(user): return PermissionDenied with transaction.atomic(): 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/budget_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_new(request): if request.method == 'POST': with transaction.atomic(): claim = models.ReimbursementClaim() claim.author = request.user claim.time = timezone.now() claim.state = models.BudgetState.DRAFT.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 }) @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 not claim.can_submit(request.user): raise PermissionDenied with transaction.atomic(): claim.update_state(request.user, models.ClaimState.AWAIT_REVIEW) 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 not claim.can_withdraw(request.user): raise PermissionDenied with transaction.atomic(): 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 not claim.can_approve(request.user): raise PermissionDenied with transaction.atomic(): 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}) return redirect(reverse('claim_view', kwargs={'id': claim.id}))