society-self-service/sstreasury/views.py

705 lines
24 KiB
Python

# 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 <https://www.gnu.org/licenses/>.
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 = 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 = []
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 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_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()
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
})