# Society Self-Service # Copyright © 2018–2023 Yingtong Li (RunasSudo) # Copyright © 2023 MUMUS Inc. # # 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.conf import settings 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.paginator import Paginator from django.core.validators import validate_email from django.db import transaction from django.db.models import Q from django.http import HttpResponse, JsonResponse 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 import csv from datetime import datetime from decimal import Decimal 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 = timezone.make_aware(datetime.strptime(form['date'], '%Y-%m-%d')) revision.date = form_date except ValueError: errors.append('Due date is not a valid date') revision.date = None else: errors.append('A due date must be specified') if form['event_dt']: try: form_event_dt = timezone.make_aware(datetime.strptime(form['event_dt'], '%Y-%m-%d %H:%M')) revision.event_dt = form_event_dt except ValueError: errors.append('Event date/time is not a valid date-time') revision.event_dt = None 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 = [] if form['cost_centre'] in settings.BUDGET_COST_CENTRES: revision.cost_centre = form['cost_centre'] else: errors.append('Cost centre is invalid') if form['approver'] in dict(settings.AVAILABLE_APPROVERS): revision.approver = form['approver'] else: errors.append('Responsible committee is invalid') 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): # Filter budgets budgets_filtered = [] for budget in models.Budget.objects.all(): revision = budget.budgetrevision_set.reverse()[0] if not revision.can_view(request.user): continue if request.GET.get('state', 'all') != 'all' and str(revision.state) != request.GET.get('state', 'all'): continue if request.GET.get('cost_centre', 'all') != 'all' and revision.cost_centre != request.GET.get('cost_centre', 'all'): continue if request.GET.get('year', '') != '' and str(revision.time.year) != request.GET.get('year', ''): continue budgets_filtered.append(revision) paginator = Paginator(budgets_filtered, 100) page = paginator.get_page(int(request.GET.get('page', '1'))) # Categorise budgets budgets_action = [] budgets_open = [] budgets_closed = [] for revision in page.object_list: 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 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 else: if state in (models.BudgetState.APPROVED, models.BudgetState.WITHDRAWN, models.BudgetState.CANCELLED): group = budgets_closed else: group = budgets_open if group is not None: group.append(revision) # Get yearly totals if request.GET.get('cost_centre', 'all') != 'all': yearly_totals = [[y, float(t)] for y, t in get_yearly_totals(budgets_filtered)] else: yearly_totals = None return render(request, 'sstreasury/budget_list.html', { 'budgets_action': budgets_action, 'budgets_open': budgets_open, 'budgets_closed': budgets_closed, 'yearly_totals': yearly_totals, 'page': page }) def get_yearly_totals(budgets_filtered): """Get total net profit per calendar year""" results = [] for year, g in itertools.groupby(sorted(budgets_filtered, key=lambda r: r.time.year), key=lambda r: r.time.year): results.append((year, sum((r.get_revenue_total() - r.get_expense_total() for r in g), Decimal('0')))) return results @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(), budget.budgetcomment_set.all(), *[r.budgetvote_set.all() for r in budget.budgetrevision_set.all()])) history.sort(key=lambda x: x.time, reverse=True) if revision.state == models.BudgetState.APPROVED.value and 'revision' not in request.GET: claims = models.ReimbursementClaim.objects.filter(Q(budget_id=str(budget.id)) | Q(budget_id__endswith='-{}'.format(budget.id))).all() claims_total_paid = sum(c.get_total() for c in claims if c.state == models.ClaimState.PAID.value) else: claims = None claims_total_paid = 0 return render(request, 'sstreasury/budget_view.html', { 'revision': revision, 'history': history, 'is_latest': 'revision' not in request.GET, 'claims': claims, 'claims_total_paid': claims_total_paid }) @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.ticketing_fee_proportion = settings.TICKETING_FEE_PROPORTION revision.ticketing_fee_fixed = settings.TICKETING_FEE_FIXED 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.ticketing_fee_proportion = settings.TICKETING_FEE_PROPORTION new_revision.ticketing_fee_fixed = settings.TICKETING_FEE_FIXED 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_viewable 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() # Get users to email about the comment users_to_email = set() # Email Treasury for user in User.objects.filter(groups__name='Treasury'): if user != request.user: users_to_email.add(user.email) # Email contributors for user in revision.contributors.all(): if user != request.user: users_to_email.add(user.email) # If endorsed budget, email committee if revision.state == models.BudgetState.ENDORSED.value: for user in User.objects.filter(groups__name=revision.approver): if user != request.user: users_to_email.add(user.email) # Send emails emailer = Emailer() for email in users_to_email: emailer.send_mail([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.WITHDRAWN) 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}) if 'Cancel' in actions: if not revision.can_cancel(request.user): raise PermissionDenied with transaction.atomic(): revision.update_state(request.user, models.BudgetState.CANCELLED) if 'SendVotingReminders' in actions: if revision.state != models.BudgetState.ENDORSED.value: raise PermissionDenied if not request.user.groups.filter(name='Executive').exists(): # TODO: Make this group configurable raise PermissionDenied # Send emails emailer = Emailer() for user in User.objects.filter(groups__name=revision.approver): # Email only if not voted yet if not revision.budgetvote_set.filter(voter=user).exists(): emailer.send_mail([user.email], 'URGENT action required: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_vote_reminder.md', {'revision': revision, 'requester': request.user}) if 'VoteInFavour' in actions or 'VoteAgainst' in actions or 'VoteAbstain' in actions: if not revision.can_vote(request.user): raise PermissionDenied if 'VoteInFavour' in actions: vote_type = models.BudgetVoteType.IN_FAVOUR elif 'VoteAgainst' in actions: vote_type = models.BudgetVoteType.AGAINST elif 'VoteAbstain' in actions: vote_type = models.BudgetVoteType.ABSTAIN # Already exists? if revision.budgetvote_set.filter(is_current=True, voter=request.user, vote_type=vote_type.value).exists(): # No need to create new vote pass else: with transaction.atomic(): # Invalidate any existing votes for vote in revision.budgetvote_set.filter(is_current=True, voter=request.user): vote.is_current = False vote.save() # Create a new vote vote = models.BudgetVote() vote.revision = revision vote.voter = request.user vote.time = timezone.now() vote.is_current = True vote.vote_type = vote_type.value vote.save() # Check if threshold for automatic approval is reached votes_in_favour = revision.budgetvote_set.filter(is_current=True, vote_type=models.BudgetVoteType.IN_FAVOUR.value).count() if votes_in_favour >= dict(settings.AVAILABLE_APPROVERS)[revision.approver][1]: # Automatically approve revision.copy() revision.time = timezone.now() revision.state = models.BudgetState.APPROVED.value revision.action = models.BudgetAction.AUTO_APPROVE.value revision.save() # Send emails users_to_email = set() for user in revision.contributors.all(): users_to_email.add(user.email) for user in User.objects.filter(groups__name=revision.approver): users_to_email.add(user.email) emailer = Emailer() for email in users_to_email: emailer.send_mail([email], 'Budget approved: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_approved.md', {'revision': revision}) return redirect(reverse('budget_view', kwargs={'id': budget.id})) @login_required def claim_list(request): # Filter claims claims_filtered = [] for claim in models.ReimbursementClaim.objects.all(): if not claim.can_view(request.user): continue if request.GET.get('state', 'all') != 'all' and str(claim.state) != request.GET.get('state', 'all'): continue if request.GET.get('year', '') != '' and str(claim.time.year) != request.GET.get('year', ''): continue claims_filtered.append(claim) paginator = Paginator(claims_filtered, 100) page = paginator.get_page(int(request.GET.get('page', '1'))) # Categorise claims claims_action = [] claims_open = [] claims_closed = [] for claim in page.object_list: 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 in [models.ClaimState.PAID, models.ClaimState.WITHDRAWN]: 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, 'page': page }) @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: budget = None bsb_lookedup = do_bsb_lookup(claim.payee_bsb) return render(request, 'sstreasury/claim_view.html', { 'claim': claim, 'budget': budget, 'history': history, 'bsb_lookedup': bsb_lookedup }) @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 bsb_lookedup = do_bsb_lookup(claim.payee_bsb) return render(request, 'sstreasury/claim_print.html', { 'claim': claim, 'budget': budget, 'bsb_lookedup': bsb_lookedup }) @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: bsb_lookedup = do_bsb_lookup(claim.payee_bsb) return render(request, 'sstreasury/claim_edit.html', { 'claim': claim, 'bsb_lookedup': bsb_lookedup }) @login_required @uses_claim @claim_viewable 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 claim.author != request.user: emailer.send_mail([claim.author.email], 'New comment on reimbursement claim: {} (RE-{})'.format(claim.purpose, 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.WITHDRAWN) 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 = models.ReimbursementClaim.objects.all() claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)] claims.sort(key=lambda c: '{}/{}{}/{}'.format(c.payee_name.strip(), c.payee_bsb.strip()[:3], c.payee_bsb.strip()[-3:], c.payee_account.strip())) try: 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.localtime(timezone.now())) # CommBank requires only one entry per payee num_records = 0 for _, payee_claims in itertools.groupby(claims, key=lambda c: '{}/{}{}/{}'.format(c.payee_name.strip(), c.payee_bsb.strip()[:3], c.payee_bsb.strip()[-3:], c.payee_account.strip())): payee_claims = list(payee_claims) reference = 'RE{}'.format(' '.join(str(c.id) for c in payee_claims)) if len(reference) > 18: # Avoid cutting a reference number in half if reference[14] == ' ': reference = reference[:14] + ' etc' else: reference = ' '.join(reference[:14].split()[:-1]) + ' etc' aba.write_detail( aba_file, dest_bsb=payee_claims[0].payee_bsb, dest_account=payee_claims[0].payee_account, cents=sum(c.get_total() for c in payee_claims)*100, dest_name=payee_claims[0].payee_name[:32], reference=reference, src_bsb=settings.ABA_SRC_BSB, src_account=settings.ABA_SRC_ACC, src_name=settings.ABA_USER_NAME ) num_records += 1 aba.write_total(aba_file, credit_cents=sum(c.get_total() for c in claims)*100, num_detail_records=num_records) aba_file.flush() response = HttpResponse(aba_file.getvalue(), content_type='text/plain') response['Content-Disposition'] = 'attachment; filename="claims.aba"' return response except aba.ABAException as ex: return render(request, 'sstreasury/claim_processing.html', { 'claims': claims, 'error': ex }) if request.POST['action'] == 'ExportXero': #claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() claims = models.ReimbursementClaim.objects.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}) if request.GET.get('view', '') == 'all': claims = models.ReimbursementClaim.objects.all() else: claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all() return render(request, 'sstreasury/claim_processing.html', { 'claims': claims }) @login_required def bsb_lookup(request): return JsonResponse({'result': do_bsb_lookup(request.GET.get('bsb', ''))}) def do_bsb_lookup(bsb): bsb = (bsb or '').replace('-', '').replace(' ', '') if len(bsb) != 6: return None bsb = '{}-{}'.format(bsb[:3], bsb[-3:]) with open(settings.BSB_FILE_PATH, 'r', newline='') as f: reader = csv.reader(f) for line in reader: if line[0] == bsb: return '{} - {}'.format(line[1], line[2]) return None