summaryrefslogtreecommitdiff
path: root/sstreasury/views.py
blob: d3b1d7ce52364166928f903bcaa7ff0225772ccd (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
1#    Society Self-Service
2#    Copyright © 2018–2020  Yingtong Li (RunasSudo)
3#
4#    This program is free software: you can redistribute it and/or modify
5#    it under the terms of the GNU Affero General Public License as published by
6#    the Free Software Foundation, either version 3 of the License, or
7#    (at your option) any later version.
8#
9#    This program is distributed in the hope that it will be useful,
10#    but WITHOUT ANY WARRANTY; without even the implied warranty of
11#    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12#    GNU Affero General Public License for more details.
13#
14#    You should have received a copy of the GNU Affero General Public License
15#    along with this program.  If not, see <https://www.gnu.org/licenses/>.
16
17from django.contrib.auth.decorators import login_required
18from django.contrib.auth.models import User
19from django.core.exceptions import PermissionDenied, ValidationError
20from django.core.validators import validate_email
21
22from django.conf import settings
23from django.db import transaction
24from django.db.models import Q
25from django.http import HttpResponse, JsonResponse
26from django.shortcuts import render, redirect
27from django.urls import reverse
28from django.utils import timezone
29from django.views import generic
30
31from . import aba
32from . import models
33from . import xero
34from ssmain.email import Emailer
35
36import csv
37from datetime import datetime
38import functools
39import io
40import itertools
41import json
42import zipfile
43
44# HELPER DECORATORS
45
46def uses_budget(viewfunc):
47	@functools.wraps(viewfunc)
48	def func(request, id):
49		budget = models.Budget.objects.get(id=id)
50		revision = budget.budgetrevision_set.reverse()[0]
51		return viewfunc(request, budget, revision)
52	return func
53
54def budget_viewable(viewfunc):
55	@functools.wraps(viewfunc)
56	def func(request, budget, revision):
57		if not revision.can_view(request.user):
58			raise PermissionDenied
59		return viewfunc(request, budget, revision)
60	return func
61
62def budget_editable(viewfunc):
63	@functools.wraps(viewfunc)
64	def func(request, budget, revision):
65		if not revision.can_edit(request.user):
66			raise PermissionDenied
67		return viewfunc(request, budget, revision)
68	return func
69
70def uses_claim(viewfunc):
71	@functools.wraps(viewfunc)
72	def func(request, id):
73		claim = models.ReimbursementClaim.objects.get(id=id)
74		return viewfunc(request, claim)
75	return func
76
77def claim_viewable(viewfunc):
78	@functools.wraps(viewfunc)
79	def func(request, claim):
80		if not claim.can_view(request.user):
81			raise PermissionDenied
82		return viewfunc(request, claim)
83	return func
84
85def claim_editable(viewfunc):
86	@functools.wraps(viewfunc)
87	def func(request, claim):
88		if not claim.can_edit(request.user):
89			raise PermissionDenied
90		return viewfunc(request, claim)
91	return func
92
93# HELPER FUNCTIONS
94
95class FormValidationError(Exception):
96	def __init__(self, data, errors):
97		super().__init__(self)
98		self.data = data
99		self.errors = errors
100
101def revision_from_form(budget, revision, form):
102	errors = []
103	
104	revision.budget = budget
105	
106	if form['name']:
107		if len(form['name']) > 100:
108			errors.append('Title must be at most 100 characters')
109		revision.name = form['name']
110	else:
111		errors.append('A title must be specified')
112	
113	if form['date']:
114		try:
115			form_date = timezone.make_aware(datetime.strptime(form['date'], '%Y-%m-%d'))
116			revision.date = form_date
117		except ValueError:
118			errors.append('Due date is not a valid date')
119			revision.date = None
120	else:
121		errors.append('A due date must be specified')
122	
123	if form['event_dt']:
124		try:
125			form_event_dt = timezone.make_aware(datetime.strptime(form['event_dt'], '%Y-%m-%d %H:%M'))
126			revision.event_dt = form_event_dt
127		except ValueError:
128			errors.append('Event date/time is not a valid date-time')
129			revision.event_dt = None
130	else:
131		revision.event_dt = None
132	
133	if form['event_attendees']:
134		if len(form['event_attendees']) > 20:
135			errors.append('Event attendees must be at most 20 characters')
136		revision.event_attendees = form['event_attendees']
137	else:
138		revision.event_attendees = None
139	
140	if form['contributors']:
141		contributors = form['contributors'].split('\n')
142		try:
143			for contributor in contributors:
144				validate_email(contributor.strip())
145		except ValidationError:
146			errors.append('Contributors contains invalid data – type only valid email addresses, one per line')
147	else:
148		contributors = []
149	
150	revision.comments = form['comments']
151	revision.revenue = json.loads(form['revenue'])
152	revision.revenue_comments = form['revenue_comments']
153	revision.expense = json.loads(form['expense'])
154	revision.expense_comments = form['expense_comments']
155	revision.expense_no_emergency_fund = True if form.get('expense_no_emergency_fund', False) else False
156	
157	if errors:
158		raise FormValidationError(revision, errors)
159	
160	revision.save()
161	
162	for contributor in contributors:
163		try:
164			user = User.objects.get(email=contributor.strip())
165		except User.DoesNotExist:
166			user = User.objects.create_user(contributor.strip().split('@')[0], contributor.strip())
167		revision.contributors.add(user)
168	
169	revision.save()
170	
171	return revision
172
173def claim_from_form(claim, form, files):
174	claim.purpose = form['purpose']
175	claim.date = form['date'] if form['date'] else None
176	claim.budget_id = form['budget_id']
177	
178	claim.comments = form['comments']
179	claim.items = json.loads(form['items'])
180	
181	claim.payee_name = form['payee_name']
182	claim.payee_bsb = form['payee_bsb']
183	claim.payee_account = form['payee_account']
184	
185	claim.save()
186	
187	if files:
188		for f in files.getlist('upload_file'):
189			claim_receipt = models.ClaimReceipt()
190			claim_receipt.claim = claim
191			claim_receipt.uploaded_file = f
192			claim_receipt.save()
193	
194	return claim
195
196# INDEX VIEW
197
198@login_required
199def index(request):
200	return render(request, 'sstreasury/index.html')
201
202# BUDGET VIEWS
203
204@login_required
205def budget_list(request):
206	budgets_action = []
207	budgets_open = []
208	budgets_closed = []
209	
210	for budget in models.Budget.objects.all():
211		revision = budget.budgetrevision_set.reverse()[0]
212		state = models.BudgetState(revision.state)
213		
214		group = None
215		
216		if request.user.groups.filter(name='Treasury').exists() and state == models.BudgetState.AWAIT_REVIEW:
217			group = budgets_action
218		elif request.user.groups.filter(name='Secretary').exists() and state == models.BudgetState.ENDORSED:
219			group = budgets_action
220		elif request.user.groups.filter(name='Committee').exists() and state == models.BudgetState.ENDORSED:
221			group = budgets_action
222		elif request.user in revision.contributors.all():
223			if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]:
224				group = budgets_action
225			elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]:
226				group = budgets_open
227			else:
228				group = budgets_closed
229		elif request.user.groups.filter(name='Treasury').exists() or request.user.groups.filter(name='Secretary').exists() or request.user.groups.filter(name='Committee').exists():
230			if state == models.BudgetState.APPROVED:
231				group = budgets_closed
232			else:
233				group = budgets_open
234		
235		if group is not None:
236			group.append(revision)
237	
238	return render(request, 'sstreasury/budget_list.html', {
239		'budgets_action': budgets_action,
240		'budgets_open': budgets_open,
241		'budgets_closed': budgets_closed
242	})
243
244@login_required
245@uses_budget
246def budget_view(request, budget, revision):
247	if 'revision' in request.GET:
248		revision = budget.budgetrevision_set.get(id=int(request.GET['revision']))
249	
250	if not revision.can_view(request.user):
251		raise PermissionDenied
252	
253	history = list(itertools.chain(budget.budgetrevision_set.all(), revision.budget.budgetcomment_set.all()))
254	history.sort(key=lambda x: x.time, reverse=True)
255	
256	if revision.state == models.BudgetState.APPROVED.value and 'revision' not in request.GET:
257		claims = models.ReimbursementClaim.objects.filter(Q(budget_id=str(budget.id)) | Q(budget_id__endswith='-{}'.format(budget.id))).all()
258	else:
259		claims = None
260	
261	return render(request, 'sstreasury/budget_view.html', {
262		'revision': revision,
263		'history': history,
264		'is_latest': 'revision' not in request.GET,
265		'claims': claims
266	})
267
268@login_required
269@uses_budget
270def budget_print(request, budget, revision):
271	if 'revision' in request.GET:
272		revision = budget.budgetrevision_set.get(id=int(request.GET['revision']))
273	
274	if not revision.can_view(request.user):
275		raise PermissionDenied
276	
277	return render(request, 'sstreasury/budget_print.html', {
278		'revision': revision,
279		'is_latest': 'revision' not in request.GET
280	})
281
282@login_required
283def budget_new(request):
284	if request.method == 'POST':
285		try:
286			with transaction.atomic():
287				budget = models.Budget()
288				budget.save()
289				revision = models.BudgetRevision()
290				revision.author = request.user
291				revision.time = timezone.now()
292				revision.action = models.BudgetAction.CREATE.value
293				revision.state = models.BudgetState.DRAFT.value
294				revision = revision_from_form(budget, revision, request.POST)
295		except FormValidationError as form_error:
296			return render(request, 'sstreasury/budget_edit.html', {
297				'revision': form_error.data,
298				'contributors': request.POST['contributors'],
299				'errors': form_error.errors
300			})
301		
302		if request.POST['submit'] == 'Save':
303			return redirect(reverse('budget_view', kwargs={'id': budget.id}))
304		else:
305			return redirect(reverse('budget_edit', kwargs={'id': budget.id}))
306	else:
307		budget = models.Budget()
308		revision = models.BudgetRevision()
309		revision.budget = budget
310		
311		return render(request, 'sstreasury/budget_edit.html', {
312			'revision': revision,
313			'contributors': request.user.email,
314			'errors': []
315		})
316
317@login_required
318@uses_budget
319@budget_editable
320def budget_edit(request, budget, revision):
321	if request.method == 'POST':
322		if request.POST['submit'] == 'Delete':
323			budget.delete()
324			return redirect(reverse('budget_list'))
325		
326		try:
327			with transaction.atomic():
328				new_revision = models.BudgetRevision()
329				new_revision.author = request.user
330				new_revision.time = timezone.now()
331				new_revision.action = models.BudgetAction.EDIT.value
332				new_revision.state = revision.state
333				new_revision = revision_from_form(budget, new_revision, request.POST)
334		except FormValidationError as form_error:
335			return render(request, 'sstreasury/budget_edit.html', {
336				'revision': form_error.data,
337				'contributors': request.POST['contributors'],
338				'errors': form_error.errors
339			})
340		
341		if request.POST['submit'] == 'Save':
342			return redirect(reverse('budget_view', kwargs={'id': budget.id}))
343		else:
344			return redirect(reverse('budget_edit', kwargs={'id': budget.id}))
345	else:
346		return render(request, 'sstreasury/budget_edit.html', {
347			'revision': revision,
348			'contributors': '\n'.join(revision.contributors.all().values_list('email', flat=True)),
349			'errors': []
350		})
351
352@login_required
353@uses_budget
354@budget_viewable
355def budget_action(request, budget, revision):
356	actions = request.POST['action'].split(',')
357	
358	if 'Comment' in actions and request.POST.get('comment', None):
359		with transaction.atomic():
360			comment = models.BudgetComment()
361			comment.budget = budget
362			comment.author = request.user
363			comment.time = timezone.now()
364			comment.content = request.POST['comment']
365			comment.save()
366			
367			emailer = Emailer()
368			for user in User.objects.filter(groups__name='Treasury'):
369				if user != request.user:
370					emailer.send_mail([user.email], 'New comment on budget: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_commented.md', {'revision': revision, 'comment': comment})
371			for user in revision.contributors.all():
372				if user != request.user:
373					emailer.send_mail([user.email], 'New comment on budget: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_commented.md', {'revision': revision, 'comment': comment})
374	
375	if 'Submit' in actions:
376		if not revision.can_submit(request.user):
377			raise PermissionDenied
378		
379		with transaction.atomic():
380			revision.update_state(request.user, models.BudgetState.AWAIT_REVIEW)
381			
382			emailer = Emailer()
383			for user in User.objects.filter(groups__name='Treasury'):
384				emailer.send_mail([user.email], 'Action required: Budget submitted: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_submitted_treasurer.md', {'revision': revision})
385			for user in revision.contributors.all():
386				emailer.send_mail([user.email], 'Budget submitted: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_submitted_drafter.md', {'revision': revision})
387	
388	if 'Withdraw' in actions:
389		if not revision.can_withdraw(request.user):
390			raise PermissionDenied
391		
392		with transaction.atomic():
393			revision.update_state(request.user, models.BudgetState.DRAFT)
394	
395	if 'Endorse' in actions:
396		if not revision.can_endorse(request.user):
397			raise PermissionDenied
398		
399		with transaction.atomic():
400			revision.update_state(request.user, models.BudgetState.ENDORSED)
401			
402			emailer = Emailer()
403			for user in User.objects.filter(groups__name='Secretary'):
404				emailer.send_mail([user.email], 'Action required: Budget endorsed: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_endorsed_secretary.md', {'revision': revision})
405			for user in revision.contributors.all():
406				emailer.send_mail([user.email], 'Budget endorsed, awaiting committee approval: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_endorsed_drafter.md', {'revision': revision})
407	
408	if 'Return' in actions:
409		if not revision.can_return(request.user):
410			raise PermissionDenied
411		
412		with transaction.atomic():
413			revision.update_state(request.user, models.BudgetState.RESUBMIT)
414			
415			emailer = Emailer()
416			for user in revision.contributors.all():
417				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})
418	
419	if 'Approve' in actions:
420		if not revision.can_approve(request.user):
421			return PermissionDenied
422		
423		with transaction.atomic():
424			revision.update_state(request.user, models.BudgetState.APPROVED)
425			
426			emailer = Emailer()
427			for user in revision.contributors.all():
428				emailer.send_mail([user.email], 'Budget approved: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_approved.md', {'revision': revision})
429	
430	if 'CmteReturn' in actions:
431		if not revision.can_cmtereturn(request.user):
432			return PermissionDenied
433		
434		with transaction.atomic():
435			revision.update_state(request.user, models.BudgetState.RESUBMIT)
436			
437			emailer = Emailer()
438			for user in revision.contributors.all():
439				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})
440	
441	return redirect(reverse('budget_view', kwargs={'id': budget.id}))
442
443@login_required
444def claim_list(request):
445	claims_action = []
446	claims_open = []
447	claims_closed = []
448	
449	for claim in models.ReimbursementClaim.objects.all():
450		state = models.ClaimState(claim.state)
451		
452		group = None
453		
454		if request.user.groups.filter(name='Treasury').exists() and state in [models.ClaimState.AWAIT_REVIEW, models.ClaimState.APPROVED]:
455			group = claims_action
456		elif request.user == claim.author:
457			if state in [models.ClaimState.DRAFT, models.ClaimState.RESUBMIT]:
458				group = claims_action
459			elif state in [models.ClaimState.AWAIT_REVIEW, models.ClaimState.APPROVED]:
460				group = claims_open
461			else:
462				group = claims_closed
463		elif request.user.groups.filter(name='Treasury').exists():
464			if state == models.ClaimState.PAID:
465				group = claims_closed
466			else:
467				group = claims_open
468		
469		if group is not None:
470			group.append(claim)
471	
472	return render(request, 'sstreasury/claim_list.html', {
473		'claims_action': claims_action,
474		'claims_open': claims_open,
475		'claims_closed': claims_closed
476	})
477
478@login_required
479def claim_new(request):
480	if request.method == 'POST':
481		with transaction.atomic():
482			claim = models.ReimbursementClaim()
483			claim.author = request.user
484			claim.time = timezone.now()
485			claim.state = models.BudgetState.DRAFT.value
486			claim = claim_from_form(claim, request.POST, request.FILES)
487			
488			claim_history = models.ClaimHistory()
489			claim_history.claim = claim
490			claim_history.author = request.user
491			claim_history.state = claim.state
492			claim_history.time = timezone.now()
493			claim_history.action = models.ClaimAction.CREATE.value
494			claim_history.save()
495		
496		if request.POST['submit'] == 'Save':
497			return redirect(reverse('claim_view', kwargs={'id': claim.id}))
498		else:
499			return redirect(reverse('claim_edit', kwargs={'id': claim.id}))
500		pass
501	else:
502		claim = models.ReimbursementClaim()
503		claim.author = request.user
504		
505		return render(request, 'sstreasury/claim_edit.html', {
506			'claim': claim
507		})
508
509@login_required
510@uses_claim
511@claim_viewable
512def claim_view(request, claim):
513	history = list(itertools.chain(claim.claimhistory_set.all(), claim.claimcomment_set.all()))
514	history.sort(key=lambda x: x.time, reverse=True)
515	
516	budget = None
517	if claim.budget_id:
518		try:
519			budget = models.Budget.objects.get(id=claim.budget_id.split('-')[-1])
520		except models.Budget.DoesNotExist:
521			budget = None
522	
523	return render(request, 'sstreasury/claim_view.html', {
524		'claim': claim,
525		'budget': budget,
526		'history': history
527	})
528
529@login_required
530@uses_claim
531@claim_viewable
532def claim_print(request, claim):
533	budget = None
534	if claim.budget_id:
535		try:
536			budget = models.Budget.objects.get(id=claim.budget_id.split('-')[-1])
537		except models.Budget.DoesNotExist:
538			budget = None
539	
540	return render(request, 'sstreasury/claim_print.html', {
541		'claim': claim,
542		'budget': budget
543	})
544
545@login_required
546@uses_claim
547@claim_editable
548def claim_edit(request, claim):
549	if request.method == 'POST':
550		if request.POST['submit'].startswith('DeleteFile'):
551			file_id = int(request.POST['submit'][10:])
552			
553			claim_receipt = models.ClaimReceipt.objects.get(id=file_id)
554			if claim_receipt.claim != claim:
555				raise PermissionDenied
556			
557			claim_receipt.delete()
558			claim_receipt.uploaded_file.delete(save=False)
559			
560			return redirect(reverse('claim_edit', kwargs={'id': claim.id}))
561		
562		if request.POST['submit'] == 'Delete':
563			claim.delete()
564			return redirect(reverse('claim_list'))
565		
566		with transaction.atomic():
567			claim = claim_from_form(claim, request.POST, request.FILES)
568			
569			claim_history = models.ClaimHistory()
570			claim_history.claim = claim
571			claim_history.author = request.user
572			claim_history.state = claim.state
573			claim_history.time = timezone.now()
574			claim_history.action = models.ClaimAction.EDIT.value
575			claim_history.save()
576		
577		if request.POST['submit'] == 'Save':
578			return redirect(reverse('claim_view', kwargs={'id': claim.id}))
579		else:
580			return redirect(reverse('claim_edit', kwargs={'id': claim.id}))
581	else:
582		return render(request, 'sstreasury/claim_edit.html', {
583			'claim': claim
584		})
585
586@login_required
587@uses_claim
588@claim_editable
589def claim_action(request, claim):
590	actions = request.POST['action'].split(',')
591	
592	if 'Comment' in actions and request.POST.get('comment', None):
593		with transaction.atomic():
594			comment = models.ClaimComment()
595			comment.claim = claim
596			comment.author = request.user
597			comment.time = timezone.now()
598			comment.content = request.POST['comment']
599			comment.save()
600			
601			emailer = Emailer()
602			for user in User.objects.filter(groups__name='Treasury'):
603				if user != request.user:
604					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})
605			if comment.author != request.user:
606				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})
607	
608	if 'Submit' in actions:
609		if not claim.can_submit(request.user):
610			raise PermissionDenied
611		
612		with transaction.atomic():
613			claim.update_state(request.user, models.ClaimState.AWAIT_REVIEW)
614			
615			emailer = Emailer()
616			for user in User.objects.filter(groups__name='Treasury'):
617				emailer.send_mail([user.email], 'Action required: Reimbursement claim submitted: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_submitted_treasurer.md', {'claim': claim})
618			emailer.send_mail([claim.author.email], 'Reimbursement claim submitted: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_submitted_drafter.md', {'claim': claim})
619	
620	if 'Withdraw' in actions:
621		if not claim.can_withdraw(request.user):
622			raise PermissionDenied
623		
624		with transaction.atomic():
625			claim.update_state(request.user, models.ClaimState.DRAFT)
626	
627	if 'Approve' in actions:
628		if not claim.can_approve(request.user):
629			raise PermissionDenied
630		
631		with transaction.atomic():
632			claim.update_state(request.user, models.ClaimState.APPROVED)
633			
634			emailer = Emailer()
635			emailer.send_mail([claim.author.email], 'Claim approved, awaiting payment: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_approved.md', {'claim': claim})
636	
637	if 'Return' in actions:
638		if not claim.can_approve(request.user):
639			raise PermissionDenied
640		
641		with transaction.atomic():
642			claim.update_state(request.user, models.ClaimState.RESUBMIT)
643			
644			emailer = Emailer()
645			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})
646	
647	return redirect(reverse('claim_view', kwargs={'id': claim.id}))
648
649@login_required
650def claim_processing(request):
651	if not request.user.groups.filter(name='Treasury').exists():
652		raise PermissionDenied
653	
654	if request.method == 'POST':
655		if request.POST['action'] == 'Export':
656			claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
657			claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)]
658			claims.sort(key=lambda c: '{}/{}{}/{}'.format(c.payee_name.strip(), c.payee_bsb.strip()[:3], c.payee_bsb.strip()[-3:], c.payee_account.strip()))
659			
660			aba_file = io.BytesIO()
661			
662			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())
663			
664			# CommBank requires only one entry per payee
665			num_records = 0
666			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())):
667				payee_claims = list(payee_claims)
668				aba.write_detail(
669					aba_file,
670					dest_bsb=payee_claims[0].payee_bsb,
671					dest_account=payee_claims[0].payee_account,
672					cents=sum(c.get_total() for c in payee_claims)*100,
673					dest_name=payee_claims[0].payee_name[:32],
674					reference='RE{}'.format(' '.join(str(c.id) for c in payee_claims)),
675					src_bsb=settings.ABA_SRC_BSB,
676					src_account=settings.ABA_SRC_ACC,
677					src_name=settings.ABA_USER_NAME
678				)
679				num_records += 1
680			
681			aba.write_total(aba_file, credit_cents=sum(c.get_total() for c in claims)*100, num_detail_records=num_records)
682			aba_file.flush()
683			
684			response = HttpResponse(aba_file.getvalue(), content_type='text/plain')
685			response['Content-Disposition'] = 'attachment; filename="claims.aba"'
686			return response
687		
688		if request.POST['action'] == 'ExportXero':
689			claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
690			claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)]
691			
692			# Export CSV
693			with io.StringIO() as csv_file:
694				csv_writer = xero.new_writer(csv_file)
695				for claim in claims:
696					xero.write_claim(csv_writer, claim)
697				
698				# Export resources to ZIP
699				with io.BytesIO() as zip_file_bytes:
700					with zipfile.ZipFile(zip_file_bytes, 'w') as zip_file:
701						zip_file.writestr('claims.csv', csv_file.getvalue())
702						
703						for claim in claims:
704							for claim_receipt in claim.claimreceipt_set.all():
705								with claim_receipt.uploaded_file.open() as f:
706									zip_file.writestr('RE-{}/{}'.format(claim.id, claim_receipt.uploaded_file.name.split('/')[-1]), f.read())
707					
708					response = HttpResponse(zip_file_bytes.getvalue(), content_type='application/zip')
709			response['Content-Disposition'] = 'attachment; filename="claims.zip"'
710			return response
711		
712		if request.POST['action'] == 'Pay':
713			claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
714			claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)]
715			
716			for claim in claims:
717				with transaction.atomic():
718					claim.update_state(request.user, models.ClaimState.PAID)
719					
720					emailer = Emailer()
721					emailer.send_mail([claim.author.email], 'Claim paid: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_paid.md', {'claim': claim})
722	
723	claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
724	
725	return render(request, 'sstreasury/claim_processing.html', {
726		'claims': claims
727	})
728
729@login_required
730def bsb_lookup(request):
731	bsb = request.GET.get('bsb', '').replace('-', '').replace(' ', '')
732	if len(bsb) != 6:
733		return JsonResponse({'result': None})
734	return JsonResponse({'result': do_bsb_lookup(bsb[:3] + '-' + bsb[-3:])})
735
736def do_bsb_lookup(bsb):
737	with open(settings.BSB_FILE_PATH, 'r', newline='') as f:
738		reader = csv.reader(f)
739		for line in reader:
740			if line[0] == bsb:
741				return '{} - {}'.format(line[1], line[2])
742	return None
Contact (issues, pull requests, etc.) at git@yingtongli.me. Generated by cgit.