# Society Self-Service # Copyright © 2018–2020 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, ValidationError from django.core.validators import validate_email from django.conf import settings from django.db import transaction from django.http import HttpResponse from django.shortcuts import render, redirect from django.urls import reverse from django.utils import timezone from django.views import generic from . import aba from . import models from . import xero from ssmain.email import Emailer from datetime import datetime import functools import io import itertools import json import zipfile # 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 class FormValidationError(Exception): def __init__(self, data, errors): super().__init__(self) self.data = data self.errors = errors def revision_from_form(budget, revision, form): errors = [] revision.budget = budget if form['name']: if len(form['name']) > 100: errors.append('Title must be at most 100 characters') revision.name = form['name'] else: errors.append('A title must be specified') if form['date']: try: form_date = datetime.strptime(form['date'], '%Y-%m-%d') except ValueError: errors.append('Due date is not a valid date') revision.date = form['date'] else: errors.append('A due date must be specified') if form['event_dt']: try: form_event_dt = datetime.strptime(form['event_dt'], '%Y-%m-%d %H:%M') except ValueError: errors.append('Event date/time is not a valid date-time') revision.event_dt = form['event_dt'] else: revision.event_dt = None if form['event_attendees']: if len(form['event_attendees']) > 20: errors.append('Event attendees must be at most 20 characters') revision.event_attendees = form['event_attendees'] else: revision.event_attendees = None if form['contributors']: contributors = form['contributors'].split('\n') try: for contributor in contributors: validate_email(contributor.strip()) except ValidationError: errors.append('Contributors contains invalid data – type only valid email addresses, one per line') else: contributors = [] 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 if errors: raise FormValidationError(revision, errors) revision.save() 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, files): claim.purpose = form['purpose'] claim.date = form['date'] if form['date'] else None claim.budget_id = form['budget_id'] claim.comments = form['comments'] claim.items = json.loads(form['items']) claim.payee_name = form['payee_name'] claim.payee_bsb = form['payee_bsb'] claim.payee_account = form['payee_account'] claim.save() if files: for f in files.getlist('upload_file'): claim_receipt = models.ClaimReceipt() claim_receipt.claim = claim claim_receipt.uploaded_file = f claim_receipt.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() or request.user.groups.filter(name='Committee').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': try: 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) except FormValidationError as form_error: return render(request, 'sstreasury/budget_edit.html', { 'revision': form_error.data, 'contributors': request.POST['contributors'], 'errors': form_error.errors }) 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, 'errors': [] }) @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')) try: with transaction.atomic(): new_revision = models.BudgetRevision() new_revision.author = request.user new_revision.time = timezone.now() new_revision.action = models.BudgetAction.EDIT.value new_revision.state = revision.state new_revision = revision_from_form(budget, new_revision, request.POST) except FormValidationError as form_error: return render(request, 'sstreasury/budget_edit.html', { 'revision': form_error.data, 'contributors': request.POST['contributors'], 'errors': form_error.errors }) 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)), 'errors': [] }) @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: {} (BU-{})'.format(revision.name, budget.id), '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: {} (BU-{})'.format(revision.name, budget.id), '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: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_submitted_treasurer.md', {'revision': revision}) for user in revision.contributors.all(): emailer.send_mail([user.email], 'Budget submitted: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_submitted_drafter.md', {'revision': revision}) if 'Withdraw' in actions: if not revision.can_withdraw(request.user): raise PermissionDenied with transaction.atomic(): revision.update_state(request.user, models.BudgetState.DRAFT) if 'Endorse' in actions: if not revision.can_endorse(request.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: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_endorsed_secretary.md', {'revision': revision}) for user in revision.contributors.all(): emailer.send_mail([user.email], 'Budget endorsed, awaiting committee approval: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_endorsed_drafter.md', {'revision': revision}) if 'Return' in actions: if not revision.can_return(request.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: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_returned.md', {'revision': revision}) if 'Approve' in actions: if not revision.can_approve(request.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: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_approved.md', {'revision': revision}) if 'CmteReturn' in actions: if not revision.can_cmtereturn(request.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: {} (BU-{})'.format(revision.name, budget.id), '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.PAID: 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, request.FILES) 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) budget = None if claim.budget_id: try: budget = models.Budget.objects.get(id=claim.budget_id.split('-')[-1]) except models.Budget.DoesNotExist: budget = None return render(request, 'sstreasury/claim_view.html', { 'claim': claim, 'budget': budget, 'history': history }) @login_required @uses_claim @claim_viewable def claim_print(request, claim): budget = None if claim.budget_id: try: budget = models.Budget.objects.get(id=claim.budget_id.split('-')[-1]) except models.Budget.DoesNotExist: budget = None return render(request, 'sstreasury/claim_print.html', { 'claim': claim, 'budget': budget }) @login_required @uses_claim @claim_editable def claim_edit(request, claim): if request.method == 'POST': if request.POST['submit'].startswith('DeleteFile'): file_id = int(request.POST['submit'][10:]) claim_receipt = models.ClaimReceipt.objects.get(id=file_id) if claim_receipt.claim != claim: raise PermissionDenied claim_receipt.delete() claim_receipt.uploaded_file.delete(save=False) return redirect(reverse('claim_edit', kwargs={'id': claim.id})) if request.POST['submit'] == 'Delete': claim.delete() return redirect(reverse('claim_list')) with transaction.atomic(): claim = claim_from_form(claim, request.POST, request.FILES) 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: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_commented.md', {'claim': claim, 'comment': comment}) if comment.author != request.user: emailer.send_mail([comment.author], 'New comment on reimbursement claim: {} (RE-{})'.format(revision.name, claim.id), '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: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_submitted_treasurer.md', {'claim': claim}) emailer.send_mail([claim.author.email], 'Reimbursement claim submitted: {} (RE-{})'.format(claim.purpose, claim.id), '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: {} (RE-{})'.format(claim.purpose, claim.id), '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: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_returned.md', {'claim': claim}) return redirect(reverse('claim_view', kwargs={'id': claim.id})) @login_required def claim_processing(request): if not request.user.groups.filter(name='Treasury').exists(): raise PermissionDenied if request.method == 'POST': if request.POST['action'] == 'Export': claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)] aba_file = io.BytesIO() aba.write_descriptive(aba_file, bank_name=settings.ABA_BANK_NAME, user_name=settings.ABA_USER_NAME, bank_code=settings.ABA_BANK_CODE, description='Reimburse', date=timezone.now()) for claim in claims: aba.write_detail(aba_file, dest_bsb=claim.payee_bsb, dest_account=claim.payee_account, cents=claim.get_total()*100, dest_name=claim.payee_name, reference='RE-{}'.format(claim.id), src_bsb=settings.ABA_SRC_BSB, src_account=settings.ABA_SRC_ACC, src_name=settings.ABA_USER_NAME) aba.write_total(aba_file, credit_cents=sum(c.get_total() for c in claims)*100, num_detail_records=len(claims)) aba_file.flush() response = HttpResponse(aba_file.getvalue(), content_type='text/plain') response['Content-Disposition'] = 'attachment; filename="claims.aba"' return response if request.POST['action'] == 'ExportXero': claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)] # Export CSV with io.StringIO() as csv_file: csv_writer = xero.new_writer(csv_file) for claim in claims: xero.write_claim(csv_writer, claim) # Export resources to ZIP with io.BytesIO() as zip_file_bytes: with zipfile.ZipFile(zip_file_bytes, 'w') as zip_file: zip_file.writestr('claims.csv', csv_file.getvalue()) for claim in claims: for claim_receipt in claim.claimreceipt_set.all(): with claim_receipt.uploaded_file.open() as f: zip_file.writestr('RE-{}/{}'.format(claim.id, claim_receipt.uploaded_file.name.split('/')[-1]), f.read()) response = HttpResponse(zip_file_bytes.getvalue(), content_type='application/zip') response['Content-Disposition'] = 'attachment; filename="claims.zip"' return response if request.POST['action'] == 'Pay': claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)] for claim in claims: with transaction.atomic(): claim.update_state(request.user, models.ClaimState.PAID) emailer = Emailer() emailer.send_mail([claim.author.email], 'Claim paid: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_paid.md', {'claim': claim}) claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() return render(request, 'sstreasury/claim_processing.html', { 'claims': claims })