869 lines
29 KiB
Python
869 lines
29 KiB
Python
# 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 <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.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
|
|
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['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):
|
|
budgets_action = []
|
|
budgets_open = []
|
|
budgets_closed = []
|
|
|
|
for budget in models.Budget.objects.all():
|
|
revision = budget.budgetrevision_set.reverse()[0]
|
|
state = models.BudgetState(revision.state)
|
|
|
|
if not revision.can_view(request.user):
|
|
continue
|
|
|
|
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)
|
|
|
|
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(), 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 '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):
|
|
# 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):
|
|
claims_action = []
|
|
claims_open = []
|
|
claims_closed = []
|
|
|
|
for claim in models.ReimbursementClaim.objects.all():
|
|
state = models.ClaimState(claim.state)
|
|
|
|
if not claim.can_view(request.user):
|
|
continue
|
|
|
|
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
|
|
})
|
|
|
|
@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
|