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 })
|