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