2018-06-26 20:14:16 +10:00
# Society Self-Service
2023-04-30 21:28:26 +10:00
# Copyright © 2018–2023 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
2018-06-26 20:14:16 +10:00
#
# 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/>.
2023-05-01 20:33:03 +10:00
from django . conf import settings
2018-06-26 20:14:16 +10:00
from django . contrib . auth . decorators import login_required
from django . contrib . auth . models import User
2020-02-12 09:53:36 +11:00
from django . core . exceptions import PermissionDenied , ValidationError
2023-05-01 20:33:03 +10:00
from django . core . paginator import Paginator
2018-06-26 20:14:16 +10:00
from django . core . validators import validate_email
from django . db import transaction
2020-05-13 10:52:31 +10:00
from django . db . models import Q
2020-06-16 12:07:52 +10:00
from django . http import HttpResponse , JsonResponse
2018-06-26 20:14:16 +10:00
from django . shortcuts import render , redirect
from django . urls import reverse
from django . utils import timezone
from django . views import generic
2020-01-05 17:54:09 +11:00
from . import aba
2018-06-26 20:14:16 +10:00
from . import models
2020-02-12 01:03:02 +11:00
from . import xero
2019-06-19 19:46:24 +10:00
from ssmain . email import Emailer
2018-06-26 20:14:16 +10:00
2020-06-16 12:07:52 +10:00
import csv
2020-02-12 09:53:36 +11:00
from datetime import datetime
2023-05-01 21:24:24 +10:00
from decimal import Decimal
2019-06-20 01:06:24 +10:00
import functools
2020-01-05 17:54:09 +11:00
import io
2018-06-26 20:14:16 +10:00
import itertools
import json
2020-02-14 15:14:51 +11:00
import zipfile
2018-06-26 20:14:16 +10:00
2019-12-29 00:30:30 +11:00
# 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
2020-02-12 09:53:36 +11:00
class FormValidationError ( Exception ) :
def __init__ ( self , data , errors ) :
super ( ) . __init__ ( self )
self . data = data
self . errors = errors
2019-12-29 00:30:30 +11:00
def revision_from_form ( budget , revision , form ) :
2020-02-12 09:53:36 +11:00
errors = [ ]
2019-12-29 00:30:30 +11:00
revision . budget = budget
2020-02-12 09:53:36 +11:00
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 ' )
2019-12-29 00:30:30 +11:00
2020-02-12 09:53:36 +11:00
if form [ ' date ' ] :
try :
2020-02-23 20:20:35 +11:00
form_date = timezone . make_aware ( datetime . strptime ( form [ ' date ' ] , ' % Y- % m- %d ' ) )
revision . date = form_date
2020-02-12 09:53:36 +11:00
except ValueError :
errors . append ( ' Due date is not a valid date ' )
2020-02-23 20:20:35 +11:00
revision . date = None
2020-02-12 09:53:36 +11:00
else :
errors . append ( ' A due date must be specified ' )
if form [ ' event_dt ' ] :
try :
2020-02-23 20:20:35 +11:00
form_event_dt = timezone . make_aware ( datetime . strptime ( form [ ' event_dt ' ] , ' % Y- % m- %d % H: % M ' ) )
revision . event_dt = form_event_dt
2020-02-12 09:53:36 +11:00
except ValueError :
errors . append ( ' Event date/time is not a valid date-time ' )
2020-02-23 20:20:35 +11:00
revision . event_dt = None
2020-02-12 09:53:36 +11:00
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 = [ ]
2020-01-17 21:12:07 +11:00
2023-05-01 20:53:44 +10:00
if form [ ' cost_centre ' ] in settings . BUDGET_COST_CENTRES :
revision . cost_centre = form [ ' cost_centre ' ]
else :
errors . append ( ' Cost centre is invalid ' )
2023-05-01 17:16:26 +10:00
if form [ ' approver ' ] in dict ( settings . AVAILABLE_APPROVERS ) :
revision . approver = form [ ' approver ' ]
else :
errors . append ( ' Responsible committee is invalid ' )
2019-12-29 00:30:30 +11:00
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
2020-02-12 09:53:36 +11:00
if errors :
raise FormValidationError ( revision , errors )
2019-12-29 00:30:30 +11:00
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
2020-01-04 16:50:31 +11:00
def claim_from_form ( claim , form , files ) :
2019-12-29 00:30:30 +11:00
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 ' ] )
2020-01-04 16:50:31 +11:00
claim . payee_name = form [ ' payee_name ' ]
claim . payee_bsb = form [ ' payee_bsb ' ]
claim . payee_account = form [ ' payee_account ' ]
2019-12-29 00:30:30 +11:00
claim . save ( )
2020-01-04 16:50:31 +11:00
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 ( )
2019-12-29 00:30:30 +11:00
return claim
# INDEX VIEW
2018-06-26 20:14:16 +10:00
@login_required
def index ( request ) :
return render ( request , ' sstreasury/index.html ' )
2019-12-29 00:30:30 +11:00
# BUDGET VIEWS
2018-06-26 20:14:16 +10:00
@login_required
def budget_list ( request ) :
2023-05-01 20:18:56 +10:00
# Filter budgets
budgets_filtered = [ ]
2018-06-26 20:14:16 +10:00
for budget in models . Budget . objects . all ( ) :
revision = budget . budgetrevision_set . reverse ( ) [ 0 ]
2019-06-20 01:06:24 +10:00
2020-07-15 16:42:03 +10:00
if not revision . can_view ( request . user ) :
continue
2023-05-01 20:18:56 +10:00
if request . GET . get ( ' state ' , ' all ' ) != ' all ' and str ( revision . state ) != request . GET . get ( ' state ' , ' all ' ) :
continue
2023-05-01 20:53:44 +10:00
if request . GET . get ( ' cost_centre ' , ' all ' ) != ' all ' and revision . cost_centre != request . GET . get ( ' cost_centre ' , ' all ' ) :
continue
2023-05-01 20:18:56 +10:00
if request . GET . get ( ' year ' , ' ' ) != ' ' and str ( revision . time . year ) != request . GET . get ( ' year ' , ' ' ) :
continue
budgets_filtered . append ( revision )
2023-05-01 20:33:03 +10:00
paginator = Paginator ( budgets_filtered , 100 )
2023-05-02 20:59:50 +10:00
page = paginator . get_page ( int ( request . GET . get ( ' page ' , ' 1 ' ) ) )
2023-05-01 20:33:03 +10:00
2023-05-01 20:18:56 +10:00
# Categorise budgets
budgets_action = [ ]
budgets_open = [ ]
budgets_closed = [ ]
2023-05-01 20:33:03 +10:00
for revision in page . object_list :
2023-05-01 20:18:56 +10:00
state = models . BudgetState ( revision . state )
2019-06-20 01:39:29 +10:00
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
2019-12-28 19:11:22 +11:00
elif request . user . groups . filter ( name = ' Committee ' ) . exists ( ) and state == models . BudgetState . ENDORSED :
group = budgets_action
2020-04-19 21:06:28 +10:00
elif request . user in revision . contributors . all ( ) :
2019-12-28 19:11:22 +11:00
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
2020-07-15 16:42:03 +10:00
else :
if state in ( models . BudgetState . APPROVED , models . BudgetState . WITHDRAWN , models . BudgetState . CANCELLED ) :
2019-12-28 19:11:22 +11:00
group = budgets_closed
2019-06-20 01:06:24 +10:00
else :
2019-12-28 19:11:22 +11:00
group = budgets_open
2019-06-20 01:39:29 +10:00
if group is not None :
group . append ( revision )
2018-06-26 20:14:16 +10:00
2023-05-01 21:24:24 +10:00
# Get yearly totals
if request . GET . get ( ' cost_centre ' , ' all ' ) != ' all ' :
yearly_totals = [ [ y , float ( t ) ] for y , t in get_yearly_totals ( budgets_filtered ) ]
else :
yearly_totals = None
2018-06-26 20:14:16 +10:00
return render ( request , ' sstreasury/budget_list.html ' , {
' budgets_action ' : budgets_action ,
' budgets_open ' : budgets_open ,
2023-05-01 20:33:03 +10:00
' budgets_closed ' : budgets_closed ,
2023-05-01 21:24:24 +10:00
' yearly_totals ' : yearly_totals ,
2023-05-01 20:33:03 +10:00
' page ' : page
2018-06-26 20:14:16 +10:00
} )
2023-05-01 21:24:24 +10:00
def get_yearly_totals ( budgets_filtered ) :
""" Get total net profit per calendar year """
results = [ ]
for year , g in itertools . groupby ( sorted ( budgets_filtered , key = lambda r : r . time . year ) , key = lambda r : r . time . year ) :
results . append ( ( year , sum ( ( r . get_revenue_total ( ) - r . get_expense_total ( ) for r in g ) , Decimal ( ' 0 ' ) ) ) )
return results
2018-06-26 20:14:16 +10:00
@login_required
2019-12-29 00:30:30 +11:00
@uses_budget
def budget_view ( request , budget , revision ) :
2018-06-26 20:14:16 +10:00
if ' revision ' in request . GET :
revision = budget . budgetrevision_set . get ( id = int ( request . GET [ ' revision ' ] ) )
2019-12-28 19:11:22 +11:00
if not revision . can_view ( request . user ) :
raise PermissionDenied
2019-06-20 15:29:57 +10:00
2023-05-01 18:28:24 +10:00
history = list ( itertools . chain ( budget . budgetrevision_set . all ( ) , budget . budgetcomment_set . all ( ) , * [ r . budgetvote_set . all ( ) for r in budget . budgetrevision_set . all ( ) ] ) )
2018-06-26 20:14:16 +10:00
history . sort ( key = lambda x : x . time , reverse = True )
2020-05-13 10:52:31 +10:00
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 ( )
2023-04-30 21:28:26 +10:00
claims_total_paid = sum ( c . get_total ( ) for c in claims if c . state == models . ClaimState . PAID . value )
2020-05-13 10:52:31 +10:00
else :
claims = None
2023-04-30 21:28:26 +10:00
claims_total_paid = 0
2020-05-13 10:52:31 +10:00
2018-06-26 20:14:16 +10:00
return render ( request , ' sstreasury/budget_view.html ' , {
' revision ' : revision ,
' history ' : history ,
2020-05-13 10:52:31 +10:00
' is_latest ' : ' revision ' not in request . GET ,
2023-04-30 21:28:26 +10:00
' claims ' : claims ,
' claims_total_paid ' : claims_total_paid
2018-06-26 20:14:16 +10:00
} )
2019-06-20 15:29:57 +10:00
@login_required
2019-12-29 00:30:30 +11:00
@uses_budget
def budget_print ( request , budget , revision ) :
2019-06-20 15:29:57 +10:00
if ' revision ' in request . GET :
revision = budget . budgetrevision_set . get ( id = int ( request . GET [ ' revision ' ] ) )
2019-12-28 19:11:22 +11:00
if not revision . can_view ( request . user ) :
raise PermissionDenied
2019-06-20 15:29:57 +10:00
return render ( request , ' sstreasury/budget_print.html ' , {
' revision ' : revision ,
' is_latest ' : ' revision ' not in request . GET
} )
2018-06-26 20:14:16 +10:00
@login_required
def budget_new ( request ) :
if request . method == ' POST ' :
2020-02-12 09:53:36 +11:00
try :
with transaction . atomic ( ) :
budget = models . Budget ( )
budget . save ( )
revision = models . BudgetRevision ( )
revision . author = request . user
revision . time = timezone . now ( )
2023-04-30 23:12:18 +10:00
revision . ticketing_fee_proportion = settings . TICKETING_FEE_PROPORTION
revision . ticketing_fee_fixed = settings . TICKETING_FEE_FIXED
2020-02-12 09:53:36 +11:00
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
} )
2018-06-26 20:14:16 +10:00
2018-12-07 15:53:46 +11:00
if request . POST [ ' submit ' ] == ' Save ' :
2018-12-07 10:51:43 +11:00
return redirect ( reverse ( ' budget_view ' , kwargs = { ' id ' : budget . id } ) )
else :
return redirect ( reverse ( ' budget_edit ' , kwargs = { ' id ' : budget . id } ) )
2018-06-26 20:14:16 +10:00
else :
budget = models . Budget ( )
revision = models . BudgetRevision ( )
revision . budget = budget
return render ( request , ' sstreasury/budget_edit.html ' , {
' revision ' : revision ,
2020-02-12 09:53:36 +11:00
' contributors ' : request . user . email ,
' errors ' : [ ]
2018-06-26 20:14:16 +10:00
} )
2019-06-20 01:06:24 +10:00
@login_required
@uses_budget
@budget_editable
def budget_edit ( request , budget , revision ) :
if request . method == ' POST ' :
2019-06-19 18:26:34 +10:00
if request . POST [ ' submit ' ] == ' Delete ' :
budget . delete ( )
return redirect ( reverse ( ' budget_list ' ) )
2020-02-12 09:53:36 +11:00
try :
with transaction . atomic ( ) :
new_revision = models . BudgetRevision ( )
new_revision . author = request . user
new_revision . time = timezone . now ( )
2023-04-30 23:12:18 +10:00
new_revision . ticketing_fee_proportion = settings . TICKETING_FEE_PROPORTION
new_revision . ticketing_fee_fixed = settings . TICKETING_FEE_FIXED
2020-02-12 09:53:36 +11:00
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
} )
2018-06-26 20:14:16 +10:00
2018-12-07 15:53:46 +11:00
if request . POST [ ' submit ' ] == ' Save ' :
2018-12-07 10:51:43 +11:00
return redirect ( reverse ( ' budget_view ' , kwargs = { ' id ' : budget . id } ) )
else :
return redirect ( reverse ( ' budget_edit ' , kwargs = { ' id ' : budget . id } ) )
2018-06-26 20:14:16 +10:00
else :
return render ( request , ' sstreasury/budget_edit.html ' , {
' revision ' : revision ,
2020-02-12 09:53:36 +11:00
' contributors ' : ' \n ' . join ( revision . contributors . all ( ) . values_list ( ' email ' , flat = True ) ) ,
' errors ' : [ ]
2018-06-26 20:14:16 +10:00
} )
@login_required
2019-06-20 01:06:24 +10:00
@uses_budget
2020-05-06 12:00:37 +10:00
@budget_viewable
2019-06-20 01:06:24 +10:00
def budget_action ( request , budget , revision ) :
actions = request . POST [ ' action ' ] . split ( ' , ' )
2018-06-26 20:14:16 +10:00
2019-06-20 01:06:24 +10:00
if ' Comment ' in actions and request . POST . get ( ' comment ' , None ) :
2019-06-19 19:46:24 +10:00
with transaction . atomic ( ) :
comment = models . BudgetComment ( )
comment . budget = budget
comment . author = request . user
comment . time = timezone . now ( )
comment . content = request . POST [ ' comment ' ]
comment . save ( )
2023-05-01 17:37:57 +10:00
# Get users to email about the comment
users_to_email = set ( )
# Email Treasury
2019-06-19 19:46:24 +10:00
for user in User . objects . filter ( groups__name = ' Treasury ' ) :
if user != request . user :
2023-05-01 17:37:57 +10:00
users_to_email . add ( user . email )
# Email contributors
2019-06-19 19:46:24 +10:00
for user in revision . contributors . all ( ) :
if user != request . user :
2023-05-01 17:37:57 +10:00
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 } )
2019-06-19 19:46:24 +10:00
2019-06-20 01:06:24 +10:00
if ' Submit ' in actions :
2019-12-29 00:30:30 +11:00
if not revision . can_submit ( request . user ) :
2019-06-19 19:46:24 +10:00
raise PermissionDenied
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
revision . update_state ( request . user , models . BudgetState . AWAIT_REVIEW )
2019-06-19 19:46:24 +10:00
emailer = Emailer ( )
for user in User . objects . filter ( groups__name = ' Treasury ' ) :
2019-12-29 00:54:44 +11:00
emailer . send_mail ( [ user . email ] , ' Action required: Budget submitted: {} (BU- {} ) ' . format ( revision . name , budget . id ) , ' sstreasury/email/budget_submitted_treasurer.md ' , { ' revision ' : revision } )
2019-06-19 19:46:24 +10:00
for user in revision . contributors . all ( ) :
2019-12-29 00:54:44 +11:00
emailer . send_mail ( [ user . email ] , ' Budget submitted: {} (BU- {} ) ' . format ( revision . name , budget . id ) , ' sstreasury/email/budget_submitted_drafter.md ' , { ' revision ' : revision } )
2019-06-19 19:46:24 +10:00
2019-06-20 01:06:24 +10:00
if ' Withdraw ' in actions :
2020-01-04 17:14:47 +11:00
if not revision . can_withdraw ( request . user ) :
2019-06-19 19:46:24 +10:00
raise PermissionDenied
2019-12-29 00:30:30 +11:00
with transaction . atomic ( ) :
2020-07-15 16:42:03 +10:00
revision . update_state ( request . user , models . BudgetState . WITHDRAWN )
2018-06-26 20:14:16 +10:00
2019-06-20 01:06:24 +10:00
if ' Endorse ' in actions :
2020-01-04 17:14:47 +11:00
if not revision . can_endorse ( request . user ) :
2019-06-20 01:06:24 +10:00
raise PermissionDenied
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
revision . update_state ( request . user , models . BudgetState . ENDORSED )
2019-06-20 01:06:24 +10:00
emailer = Emailer ( )
2019-06-20 01:39:29 +10:00
for user in User . objects . filter ( groups__name = ' Secretary ' ) :
2019-12-29 00:54:44 +11:00
emailer . send_mail ( [ user . email ] , ' Action required: Budget endorsed: {} (BU- {} ) ' . format ( revision . name , budget . id ) , ' sstreasury/email/budget_endorsed_secretary.md ' , { ' revision ' : revision } )
2019-06-20 01:06:24 +10:00
for user in revision . contributors . all ( ) :
2019-12-29 00:54:44 +11:00
emailer . send_mail ( [ user . email ] , ' Budget endorsed, awaiting committee approval: {} (BU- {} ) ' . format ( revision . name , budget . id ) , ' sstreasury/email/budget_endorsed_drafter.md ' , { ' revision ' : revision } )
2019-06-20 01:06:24 +10:00
if ' Return ' in actions :
2020-01-04 17:14:47 +11:00
if not revision . can_return ( request . user ) :
2019-06-20 01:06:24 +10:00
raise PermissionDenied
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
revision . update_state ( request . user , models . BudgetState . RESUBMIT )
2019-06-20 01:06:24 +10:00
emailer = Emailer ( )
for user in revision . contributors . all ( ) :
2019-12-29 00:54:44 +11:00
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 } )
2019-06-20 01:06:24 +10:00
2019-06-20 01:39:29 +10:00
if ' Approve ' in actions :
2020-01-04 17:14:47 +11:00
if not revision . can_approve ( request . user ) :
2019-12-29 00:30:30 +11:00
return PermissionDenied
2019-06-20 01:39:29 +10:00
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
revision . update_state ( request . user , models . BudgetState . APPROVED )
2019-06-20 01:39:29 +10:00
emailer = Emailer ( )
for user in revision . contributors . all ( ) :
2019-12-29 00:54:44 +11:00
emailer . send_mail ( [ user . email ] , ' Budget approved: {} (BU- {} ) ' . format ( revision . name , budget . id ) , ' sstreasury/email/budget_approved.md ' , { ' revision ' : revision } )
2019-06-20 01:39:29 +10:00
if ' CmteReturn ' in actions :
2020-01-04 17:14:47 +11:00
if not revision . can_cmtereturn ( request . user ) :
2019-12-29 00:30:30 +11:00
return PermissionDenied
2019-06-20 01:39:29 +10:00
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
revision . update_state ( request . user , models . BudgetState . RESUBMIT )
2019-06-20 01:39:29 +10:00
emailer = Emailer ( )
for user in revision . contributors . all ( ) :
2019-12-29 00:54:44 +11:00
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 } )
2019-06-20 01:39:29 +10:00
2020-07-15 16:42:03 +10:00
if ' Cancel ' in actions :
if not revision . can_cancel ( request . user ) :
raise PermissionDenied
with transaction . atomic ( ) :
revision . update_state ( request . user , models . BudgetState . CANCELLED )
2023-05-01 19:26:18 +10:00
if ' SendVotingReminders ' in actions :
if revision . state != models . BudgetState . ENDORSED . value :
raise PermissionDenied
if not request . user . groups . filter ( name = ' Executive ' ) . exists ( ) :
# TODO: Make this group configurable
raise PermissionDenied
# Send emails
emailer = Emailer ( )
for user in User . objects . filter ( groups__name = revision . approver ) :
# Email only if not voted yet
if not revision . budgetvote_set . filter ( voter = user ) . exists ( ) :
emailer . send_mail ( [ user . email ] , ' URGENT action required: {} (BU- {} ) ' . format ( revision . name , budget . id ) , ' sstreasury/email/budget_vote_reminder.md ' , { ' revision ' : revision , ' requester ' : request . user } )
2023-05-01 18:28:24 +10:00
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?
2023-05-01 19:26:18 +10:00
if revision . budgetvote_set . filter ( is_current = True , voter = request . user , vote_type = vote_type . value ) . exists ( ) :
2023-05-01 18:28:24 +10:00
# 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 ( )
2023-05-01 18:47:56 +10:00
# 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 } )
2023-05-01 18:28:24 +10:00
2018-06-26 20:14:16 +10:00
return redirect ( reverse ( ' budget_view ' , kwargs = { ' id ' : budget . id } ) )
2019-12-28 19:11:22 +11:00
@login_required
def claim_list ( request ) :
2023-05-01 20:18:56 +10:00
# Filter claims
2023-05-01 20:33:03 +10:00
claims_filtered = [ ]
2023-05-01 20:18:56 +10:00
for claim in models . ReimbursementClaim . objects . all ( ) :
if not claim . can_view ( request . user ) :
continue
if request . GET . get ( ' state ' , ' all ' ) != ' all ' and str ( claim . state ) != request . GET . get ( ' state ' , ' all ' ) :
continue
if request . GET . get ( ' year ' , ' ' ) != ' ' and str ( claim . time . year ) != request . GET . get ( ' year ' , ' ' ) :
continue
2023-05-01 20:33:03 +10:00
claims_filtered . append ( claim )
paginator = Paginator ( claims_filtered , 100 )
2023-05-02 20:59:50 +10:00
page = paginator . get_page ( int ( request . GET . get ( ' page ' , ' 1 ' ) ) )
2023-05-01 20:18:56 +10:00
# Categorise claims
2019-12-28 19:11:22 +11:00
claims_action = [ ]
claims_open = [ ]
claims_closed = [ ]
2023-05-01 20:33:03 +10:00
for claim in page . object_list :
2019-12-28 19:11:22 +11:00
state = models . ClaimState ( claim . state )
group = None
if request . user . groups . filter ( name = ' Treasury ' ) . exists ( ) and state in [ models . ClaimState . AWAIT_REVIEW , models . ClaimState . APPROVED ] :
group = claims_action
elif request . user == claim . author :
if state in [ models . ClaimState . DRAFT , models . ClaimState . RESUBMIT ] :
group = claims_action
elif state in [ models . ClaimState . AWAIT_REVIEW , models . ClaimState . APPROVED ] :
group = claims_open
else :
group = claims_closed
elif request . user . groups . filter ( name = ' Treasury ' ) . exists ( ) :
2020-07-15 17:03:52 +10:00
if state in [ models . ClaimState . PAID , models . ClaimState . WITHDRAWN ] :
2019-12-28 19:11:22 +11:00
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 ,
2023-05-01 20:33:03 +10:00
' claims_closed ' : claims_closed ,
' page ' : page
2019-12-28 19:11:22 +11:00
} )
@login_required
def claim_new ( request ) :
if request . method == ' POST ' :
with transaction . atomic ( ) :
claim = models . ReimbursementClaim ( )
claim . author = request . user
claim . time = timezone . now ( )
2019-12-28 23:23:56 +11:00
claim . state = models . BudgetState . DRAFT . value
2020-01-04 16:50:31 +11:00
claim = claim_from_form ( claim , request . POST , request . FILES )
2019-12-28 19:11:22 +11:00
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
} )
2019-12-28 23:23:56 +11:00
@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 )
2019-12-29 00:54:44 +11:00
budget = None
if claim . budget_id :
try :
budget = models . Budget . objects . get ( id = claim . budget_id . split ( ' - ' ) [ - 1 ] )
2020-09-06 16:51:47 +10:00
except :
2019-12-29 00:54:44 +11:00
budget = None
2020-06-16 12:14:49 +10:00
bsb_lookedup = do_bsb_lookup ( claim . payee_bsb )
2019-12-28 23:23:56 +11:00
return render ( request , ' sstreasury/claim_view.html ' , {
' claim ' : claim ,
2019-12-29 00:54:44 +11:00
' budget ' : budget ,
2020-06-16 12:14:49 +10:00
' history ' : history ,
' bsb_lookedup ' : bsb_lookedup
2019-12-28 23:23:56 +11:00
} )
@login_required
@uses_claim
@claim_viewable
def claim_print ( request , claim ) :
2019-12-29 00:54:44 +11:00
budget = None
if claim . budget_id :
try :
budget = models . Budget . objects . get ( id = claim . budget_id . split ( ' - ' ) [ - 1 ] )
except models . Budget . DoesNotExist :
budget = None
2020-06-16 12:14:49 +10:00
bsb_lookedup = do_bsb_lookup ( claim . payee_bsb )
2019-12-28 23:23:56 +11:00
return render ( request , ' sstreasury/claim_print.html ' , {
2019-12-29 00:54:44 +11:00
' claim ' : claim ,
2020-06-16 12:14:49 +10:00
' budget ' : budget ,
' bsb_lookedup ' : bsb_lookedup
2019-12-28 23:23:56 +11:00
} )
@login_required
@uses_claim
@claim_editable
def claim_edit ( request , claim ) :
if request . method == ' POST ' :
2020-01-04 16:50:31 +11:00
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 } ) )
2019-12-28 23:23:56 +11:00
if request . POST [ ' submit ' ] == ' Delete ' :
claim . delete ( )
return redirect ( reverse ( ' claim_list ' ) )
with transaction . atomic ( ) :
2020-01-04 16:50:31 +11:00
claim = claim_from_form ( claim , request . POST , request . FILES )
2019-12-28 23:23:56 +11:00
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 :
2020-06-16 12:28:07 +10:00
bsb_lookedup = do_bsb_lookup ( claim . payee_bsb )
2019-12-28 23:23:56 +11:00
return render ( request , ' sstreasury/claim_edit.html ' , {
2020-06-16 12:28:07 +10:00
' claim ' : claim ,
' bsb_lookedup ' : bsb_lookedup
2019-12-28 23:23:56 +11:00
} )
@login_required
@uses_claim
2020-10-04 15:12:55 +11:00
@claim_viewable
2019-12-28 23:23:56 +11:00
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 :
2019-12-29 00:54:44 +11:00
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 } )
2020-07-18 16:58:06 +10:00
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 } )
2019-12-28 23:23:56 +11:00
if ' Submit ' in actions :
2019-12-29 00:30:30 +11:00
if not claim . can_submit ( request . user ) :
2019-12-28 23:23:56 +11:00
raise PermissionDenied
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
claim . update_state ( request . user , models . ClaimState . AWAIT_REVIEW )
2019-12-28 23:23:56 +11:00
emailer = Emailer ( )
for user in User . objects . filter ( groups__name = ' Treasury ' ) :
2019-12-29 00:54:44 +11:00
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 } )
2019-12-28 23:23:56 +11:00
if ' Withdraw ' in actions :
2019-12-29 00:30:30 +11:00
if not claim . can_withdraw ( request . user ) :
2019-12-28 23:23:56 +11:00
raise PermissionDenied
2019-12-29 00:30:30 +11:00
with transaction . atomic ( ) :
2020-07-15 17:03:52 +10:00
claim . update_state ( request . user , models . ClaimState . WITHDRAWN )
2019-12-28 23:23:56 +11:00
if ' Approve ' in actions :
2019-12-29 00:30:30 +11:00
if not claim . can_approve ( request . user ) :
2019-12-28 23:23:56 +11:00
raise PermissionDenied
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
claim . update_state ( request . user , models . ClaimState . APPROVED )
2019-12-28 23:23:56 +11:00
emailer = Emailer ( )
2019-12-29 00:54:44 +11:00
emailer . send_mail ( [ claim . author . email ] , ' Claim approved, awaiting payment: {} (RE- {} ) ' . format ( claim . purpose , claim . id ) , ' sstreasury/email/claim_approved.md ' , { ' claim ' : claim } )
2019-12-28 23:23:56 +11:00
if ' Return ' in actions :
2019-12-29 00:30:30 +11:00
if not claim . can_approve ( request . user ) :
2019-12-28 23:23:56 +11:00
raise PermissionDenied
with transaction . atomic ( ) :
2019-12-29 00:30:30 +11:00
claim . update_state ( request . user , models . ClaimState . RESUBMIT )
2019-12-28 23:23:56 +11:00
emailer = Emailer ( )
2019-12-29 00:54:44 +11:00
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 } )
2019-12-28 23:23:56 +11:00
return redirect ( reverse ( ' claim_view ' , kwargs = { ' id ' : claim . id } ) )
2020-01-05 17:54:09 +11:00
@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 ' :
2020-07-26 03:55:47 +10:00
#claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
claims = models . ReimbursementClaim . objects . all ( )
2020-01-05 17:54:09 +11:00
claims = [ c for c in claims if request . POST . get ( ' claim_ {} ' . format ( c . id ) , False ) ]
2020-06-01 22:55:51 +10:00
claims . sort ( key = lambda c : ' {} / {} {} / {} ' . format ( c . payee_name . strip ( ) , c . payee_bsb . strip ( ) [ : 3 ] , c . payee_bsb . strip ( ) [ - 3 : ] , c . payee_account . strip ( ) ) )
2020-01-05 17:54:09 +11:00
2021-03-02 10:16:09 +11:00
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 )
2021-03-02 10:24:09 +11:00
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 '
2021-03-02 10:16:09 +11:00
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 ] ,
2021-03-02 10:24:09 +11:00
reference = reference ,
2021-03-02 10:16:09 +11:00
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
} )
2020-01-05 17:54:09 +11:00
2020-02-12 01:03:02 +11:00
if request . POST [ ' action ' ] == ' ExportXero ' :
2020-07-26 03:55:47 +10:00
#claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
claims = models . ReimbursementClaim . objects . all ( )
2020-02-12 01:03:02 +11:00
claims = [ c for c in claims if request . POST . get ( ' claim_ {} ' . format ( c . id ) , False ) ]
2020-02-14 15:14:51 +11:00
# 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 " '
2020-02-12 01:03:02 +11:00
return response
2020-01-05 17:54:09 +11:00
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 } )
2020-07-26 03:55:47 +10:00
if request . GET . get ( ' view ' , ' ' ) == ' all ' :
claims = models . ReimbursementClaim . objects . all ( )
else :
claims = models . ReimbursementClaim . objects . filter ( state = models . ClaimState . APPROVED . value ) . all ( )
2020-01-05 17:54:09 +11:00
return render ( request , ' sstreasury/claim_processing.html ' , {
' claims ' : claims
} )
2020-06-16 12:07:52 +10:00
@login_required
def bsb_lookup ( request ) :
2020-06-16 12:14:49 +10:00
return JsonResponse ( { ' result ' : do_bsb_lookup ( request . GET . get ( ' bsb ' , ' ' ) ) } )
2020-06-16 12:07:52 +10:00
def do_bsb_lookup ( bsb ) :
2020-06-16 12:14:49 +10:00
bsb = ( bsb or ' ' ) . replace ( ' - ' , ' ' ) . replace ( ' ' , ' ' )
if len ( bsb ) != 6 :
return None
bsb = ' {} - {} ' . format ( bsb [ : 3 ] , bsb [ - 3 : ] )
2020-06-16 12:07:52 +10:00
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