Compare commits

..

115 Commits

Author SHA1 Message Date
ec370b47cd
Allow customising ticketing fee name 2023-10-19 20:13:43 +11:00
d967a548a4
Fix pagination on budget list page 2023-05-02 21:17:10 +10:00
bd43db4e21
Use Paginator.get_page instead of Paginator.page
Avoid error on empty page
2023-05-02 21:04:11 +10:00
603e276bac
Graph previous year totals and year-to-date totals next to budget listing/filtering results 2023-05-01 21:24:24 +10:00
8d3a7b7ed0
Tag budgets by cost centre, and allow filtering budgets by cost centre 2023-05-01 20:53:44 +10:00
4b3314a0f4
Implement pagination for budgets/claims 2023-05-01 20:33:03 +10:00
1217770900
Implement basic filtering of claims/budgets 2023-05-01 20:18:56 +10:00
8a3a09d8ab
Implement sending urgent reminder emails to all members of the applicable committee for an endorsed budget 2023-05-01 19:26:23 +10:00
490faa8147
Automatically approve budget when reached threshold for approval 2023-05-01 18:49:49 +10:00
7f6bf1b07f
Do not attempt to render budget graph when no data available 2023-05-01 18:28:42 +10:00
5994cd38b6
Implement voting on budgets 2023-05-01 18:28:24 +10:00
e098ed4f01
Automatically email all members of the applicable committee when a comment is left on an endorsed budget 2023-05-01 17:37:57 +10:00
4f84e8a99f
Update EMAIL_DEBUG to use/support evolution 2023-05-01 17:37:38 +10:00
5cbb872c8e
Allow specifying the responsible committee for a budget 2023-05-01 17:16:26 +10:00
2b976fd19e
Do not show budget graphs if no data 2023-04-30 23:12:30 +10:00
0622ac2a89
Allow customising ticketing fee calculation
Existing ticketing fees can be preserved at database migration time
2023-04-30 23:12:18 +10:00
faa81ca803
Display total reimbursement amount per budget 2023-04-30 21:28:26 +10:00
9c0d7122f6
Add budget graphs
On the budget page, add a pie chart of expenses, and a bar chart comparing expenses to revenue.
2023-04-30 21:17:07 +10:00
8cce90f83b
Update dependencies etc. 2023-04-30 20:35:53 +10:00
22d9525daa
Update renewal email text 2022-03-01 19:15:46 +11:00
67060e0f04
Add --all flag to send_renewal_email command 2022-03-01 19:15:45 +11:00
5059c5f793
Membership renewal when promotions module is disabled 2022-03-01 19:10:17 +11:00
98a163f63a
Don't error on invalid unit price, etc. 2022-02-28 19:14:20 +11:00
5c8c4750a5
Make budget ID mandatory for reimbursements 2022-01-26 21:44:35 +11:00
7d337c92d1
Fix bug with too many reimbursement claims at once 2021-03-02 10:24:09 +11:00
6be52fb718
Display ABA errors on export page 2021-03-02 10:18:30 +11:00
44ba18d22e
Allow fractional units 2020-11-17 19:06:19 +11:00
057a97b33a
Fix logic error resulting in ABA dates with incorrect timezone 2020-10-05 00:32:17 +11:00
2dde94483f
Don't choke on reimbursement claims with fractional units 2020-10-05 00:11:12 +11:00
066b461769
Don't send mail to expired members 2020-10-04 15:13:19 +11:00
802ff7343f
Allow commenting on submitted claim 2020-10-04 15:12:55 +11:00
65076f0165
Rename "Save" button to "Save as draft" 2020-09-09 23:15:24 +10:00
7299d7d414
Don't error on invalid budget ID for reimbursement claim 2020-09-06 16:51:47 +10:00
4aef60b99b
Tweak formatting of comment emails 2020-08-28 11:33:58 +10:00
b7d6d0c5d4
Add reimbursement claims endpoint for all reimbursement claims 2020-07-26 03:55:47 +10:00
ae128d209c
Correct logic error resulting in reimbursement claim comments not being sent 2020-07-18 17:02:12 +10:00
5bf2f310d6
Hide jsGrid filter/insert rows in mobile view correctly on all pages 2020-07-16 17:08:08 +10:00
eb46c82ae1
Add withdrawn reimbursement claim state 2020-07-15 17:03:52 +10:00
1344f612f8
Add withdrawn, cancelled budget states 2020-07-15 16:53:22 +10:00
e7e39ef66f
Implement BSB lookup on claim edit page 2020-06-16 12:28:07 +10:00
b2913eac9d
Show BSB lookup on claim page 2020-06-16 12:17:18 +10:00
10f0cd4121
Implement BSB lookup 2020-06-16 12:11:30 +10:00
8d31fa1af6
Better ABA handling 2020-06-15 20:37:43 +10:00
e28a814edf
Ditto for reimbursement claims 2020-06-08 01:11:10 +10:00
a1aa2423ad
Fix withdraw permission logic 2020-06-07 21:36:10 +10:00
74d4c7b333
Combine entries for same payee within ABA file 2020-06-01 23:07:22 +10:00
bbd775789f
Include claim purpose when exporting to Xero 2020-05-13 11:18:57 +10:00
787b16733d
Cross-reference reimbursement claims on budget page 2020-05-13 10:58:57 +10:00
435aa15d17
Don't send emails if membership expired, etc. 2020-05-11 12:59:45 +10:00
62119cc83c
Better programmatic email generation
Allow different content for HTML vs text
Automatically strip/tidy up text output
2020-05-10 22:32:49 +10:00
fc1bb22dc4
Allow commenting on budget even if not editable 2020-05-06 12:00:37 +10:00
3a49607b83
Do not automatically classify budgets based on "author"ship 2020-04-19 21:06:28 +10:00
3168b52479
Fix ABA output 2020-04-08 16:50:10 +10:00
fbe0396a8e
Add command to send renewal emails 2020-03-10 21:13:53 +11:00
b725b46f2f
Implement membership renewal 2020-03-10 17:29:57 +11:00
187f3a9b7c
Add management commands to find duplicate users/members 2020-03-10 16:49:27 +11:00
1cd09562a9
Allow directly marking committee returned from await review stage 2020-03-02 14:31:19 +11:00
0dc5923429
Display budget comments, etc. as Markdown 2020-02-27 19:27:05 +11:00
17637a4cfd
Allow committee to view submitted budgets 2020-02-27 17:55:56 +11:00
e69efdc816
Fix error when dates invalid 2020-02-23 20:35:03 +11:00
28eb72ef81
List closed budgets for committee members 2020-02-16 21:15:05 +11:00
ecfa4bdc2b
Export reimbursement claim receipts 2020-02-14 15:22:23 +11:00
dbe1eb988c
Actually preserve claim state when editing 2020-02-13 22:36:52 +11:00
f091b20773
Add users to group by email 2020-02-12 16:02:12 +11:00
1975c48273
Validate budget input 2020-02-12 10:01:49 +11:00
0da9bebcae
Allow newlines in budget textareas 2020-02-12 09:33:12 +11:00
1324195a1a
Categorise paid claims as closed 2020-02-12 01:11:51 +11:00
e5bbcaabbb
Implement Xero export for reimbursements 2020-02-12 01:05:33 +11:00
a170d1ea89
Disallow Treasurer to approve claim when already approved 2020-02-09 22:18:44 +11:00
16f859cb6e
Don't reset claim state when editing 2020-02-09 22:17:02 +11:00
83ef0c5154
Allow Treasurer to edit claim 2020-02-09 22:15:22 +11:00
95bc4c3f2a
Fix error when Treasurer attempts to edit a reimbursement claim 2020-02-05 12:27:26 +11:00
eb411a78a2
Fix mdx_urlize for emails 2020-01-27 16:45:47 +11:00
e5803e1b41
Fork mdx_urlize, also urlize .au domains 2020-01-26 17:58:47 +11:00
005d1980ae
Update IWT fee calculation 2020-01-26 03:20:26 +11:00
6df1fe78a4
Also allow Treasurer to mark committee actions 2020-01-25 23:19:20 +11:00
4e1e9a611e
Allow Treasurer/Secretary to skip steps 2020-01-25 23:18:56 +11:00
a9021faedc
Allow arbitrary strings for event attendees field 2020-01-25 23:17:21 +11:00
392e26636f
Minor tweaks to budget/claim edit 2020-01-20 18:20:54 +11:00
f32510d6a3
Improve budget display on mobile
Stack sidebar
Hide filter/insert grid rows
2020-01-19 00:02:11 +11:00
43fd262430
Add total profit/loss to budget display 2020-01-17 21:30:52 +11:00
d4419792b2
Add help tooltips to budget form 2020-01-17 21:14:41 +11:00
858eb0564f
Add event details fields to budget model/interface 2020-01-17 21:12:07 +11:00
91dddaf694
Rename budget name to budget title 2020-01-17 21:11:48 +11:00
f46f966bbb
Fix bug editing budgets 2020-01-07 22:13:56 +11:00
cdb3518b28
Import ABA export and claim payment 2020-01-05 17:54:09 +11:00
77102d7203
Use modal dialogs for confirmations 2020-01-04 21:41:36 +11:00
2936433689
Fix typos in budget editor 2020-01-04 17:14:47 +11:00
e49d2d963a
Ensures bulletin sends correctly if running across midnight 2020-01-04 17:14:34 +11:00
96e45c0c5c
Add bank details and file uploads for reimbursement claims 2020-01-04 16:50:31 +11:00
8ef85addd0
Show uploaded files in bulletin editor 2020-01-04 16:49:58 +11:00
984ed2432d
Make budget/claim IDs more prominent 2019-12-29 00:54:44 +11:00
b09b8e2660
Tidying up labels 2019-12-29 00:34:09 +11:00
c4eb45514f
Centralise access control logic 2019-12-29 00:30:30 +11:00
b70ef9b8f6
Reimbursement claim workflow 2019-12-28 23:23:56 +11:00
f3287e127a
Basic reimbursement claim data model and interface 2019-12-28 19:11:22 +11:00
9ff988d2e9
Apply premailer to emails sent using Emailer 2019-09-20 00:45:30 +10:00
9430c4cf98
Add command to send arbitrary emails 2019-09-20 00:40:15 +10:00
cb45c5b63a
Fix colour of lists in emails 2019-09-20 00:34:34 +10:00
acd26e7160
Do not send bulletin email if no items 2019-08-11 22:48:50 +10:00
487c2f3650
Fix bulletin timezones 2019-07-22 09:33:20 +10:00
0d44b8ec78
Budget print view 2019-06-20 15:29:57 +10:00
9e1cc75287
Basic print stylesheets 2019-06-20 15:17:06 +10:00
a43c7da302
Committee approval of budgets 2019-06-20 01:39:29 +10:00
69a7c052c1
Treasury endorsement/return of budgets 2019-06-20 01:13:23 +10:00
a0d6164d95
When debugging, don't send emails 2019-06-20 01:05:49 +10:00
1afc3f3db7
Implement submitting/withdrawing budgets and email sending 2019-06-19 19:59:13 +10:00
2a5bf7ad93
Move delete buttons to edit page 2019-06-19 18:31:54 +10:00
2d9ea3b311
Make emergency fund optional and consolidate budget JS code 2019-06-19 17:38:20 +10:00
2ca59c0684
Minor budget edit/view cosmetic improvements 2019-06-19 17:03:52 +10:00
ff29819ca1
Add associate membership registration 2019-06-19 16:30:01 +10:00
1e3ff7475a
Add associate membership model fields 2019-06-19 16:04:00 +10:00
664a31500f
Update example settings 2019-05-05 20:08:35 +10:00
97794b521f
Paginate past bulletin items 2019-05-05 20:03:39 +10:00
3193d06891
Increase limits to 1000 characters 2019-05-05 17:38:01 +10:00
78 changed files with 4378 additions and 1380 deletions

1
.gitignore vendored
View File

@ -8,5 +8,4 @@ settings.py
**/migrations/**
!**/migrations/__init__.py
static
promo_uploads

View File

@ -1,11 +1,10 @@
Django==2.1.5
Jinja2==2.10
Jinja2==3.1.2
social-auth-app-django==2.1.0
jsonfield==2.0.2
Pillow==5.4.1
Markdown==3.0.1
google-api-python-client==1.7.7
django-ratelimit==2.0.0
boto3==1.9.86
boto3==1.26.79
premailer==3.2.0
markdown-urlize==0.2.0

View File

@ -1,5 +1,5 @@
# Society Self-Service
# Copyright © 2018 Yingtong Li (RunasSudo)
# Copyright © 2018-2023 Yingtong Li (RunasSudo)
#
# 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
@ -22,10 +22,13 @@ from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from jinja2 import Environment, Markup, select_autoescape
from jinja2 import Environment, select_autoescape
from markupsafe import Markup
import importlib
from .mdx_urlize import UrlizeExtension
def environment(**options):
options['autoescape'] = select_autoescape(
disabled_extensions=('txt',),
@ -42,6 +45,6 @@ def environment(**options):
'MEDIA_URL': settings.MEDIA_URL,
})
env.filters.update({
'markdown': lambda x: Markup(markdown.markdown(x, extensions=['nl2br', 'mdx_urlize']))
'markdown': lambda x: Markup(markdown.markdown(x, extensions=['nl2br', UrlizeExtension()]))
})
return env

View File

@ -0,0 +1,27 @@
This is a 2-clause BSD license (http://opensource.org/licenses/BSD-2-Clause)
Copyright (c) 2014 Rowan Nairn
All rights reserved.
Redistribution and use in source and binary forms, with or without
modification, are permitted provided that the following conditions are
met:
1. Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimer.
2. Redistributions in binary form must reproduce the above copyright
notice, this list of conditions and the following disclaimer in the
documentation and/or other materials provided with the distribution.
THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS
"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT
LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR
A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT
HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL,
SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT
LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY
THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.

101
selfserv/mdx_urlize.py Normal file
View File

@ -0,0 +1,101 @@
# Society Self-Service
# Copyright © 2018-2020 Yingtong Li (RunasSudo)
#
# 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/>.
#
# Adapted from:
# Copyright (c) 2014 Rowan Nairn. All rights reserved. Licensed under the 2-clause BSD licence
# https://github.com/r0wb0t/markdown-urlize
"""A more liberal autolinker
Inspired by Django's urlize function.
Positive examples:
>>> import markdown
>>> md = markdown.Markdown(extensions=['urlize'])
>>> md.convert('http://example.com/')
u'<p><a href="http://example.com/">http://example.com/</a></p>'
>>> md.convert('go to http://example.com')
u'<p>go to <a href="http://example.com">http://example.com</a></p>'
>>> md.convert('example.com')
u'<p><a href="http://example.com">example.com</a></p>'
>>> md.convert('example.net')
u'<p><a href="http://example.net">example.net</a></p>'
>>> md.convert('www.example.us')
u'<p><a href="http://www.example.us">www.example.us</a></p>'
>>> md.convert('(www.example.us/path/?name=val)')
u'<p>(<a href="http://www.example.us/path/?name=val">www.example.us/path/?name=val</a>)</p>'
>>> md.convert('go to <http://example.com> now!')
u'<p>go to <a href="http://example.com">http://example.com</a> now!</p>'
Negative examples:
>>> md.convert('del.icio.us')
u'<p>del.icio.us</p>'
"""
import markdown
# Global Vars
URLIZE_RE = '(%s)' % '|'.join([
r'<(?:f|ht)tps?://[^>]*>',
r'\b(?:f|ht)tps?://[^)<>\s]+[^.,)<>\s]',
r'\bwww\.[^)<>\s]+[^.,)<>\s]',
r'[^(<\s]+\.(?:(?:com|net|org)(?:\.au)?)\b',
])
class UrlizePattern(markdown.inlinepatterns.Pattern):
""" Return a link Element given an autolink (`http://example/com`). """
def handleMatch(self, m):
url = m.group(2)
if url.startswith('<'):
url = url[1:-1]
text = url
if not url.split('://')[0] in ('http','https','ftp'):
if '@' in url and not '/' in url:
url = 'mailto:' + url
else:
url = 'http://' + url
el = markdown.util.etree.Element("a")
el.set('href', url)
el.text = markdown.util.AtomicString(text)
return el
class UrlizeExtension(markdown.Extension):
""" Urlize Extension for Python-Markdown. """
def extendMarkdown(self, md, md_globals):
""" Replace autolink with UrlizePattern """
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
def makeExtension(*args, **kwargs):
return UrlizeExtension(*args, **kwargs)
if __name__ == "__main__":
import doctest
doctest.testmod()

View File

@ -1,5 +1,6 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
# Copyright © 2018-2023 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@ -20,8 +21,10 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = None # IMPORTANT: Set this to a secret string
SECRET_KEY_MEMBERSIG = None # IMPORTANT: Set this to a secret string
DEBUG = True
EMAIL_DEBUG = False
ALLOWED_HOSTS = []
@ -30,11 +33,36 @@ PROMO_LOGO_URL = 'https://placehold.it/2000x500'
PROMO_LOGO_LINK = 'https://example.com'
PROMO_GROUPS_MANDATORY = ['All Years']
AVAILABLE_APPROVERS = [
# Tuples (committee name, (description, votes required to approve))
('Committee', ('Management Committee', 10)),
]
BUDGET_ENABLE_VOTING = True
TICKETING_FEE_NAME = 'Fee'
TICKETING_FEE_PROPORTION = 0.0175 # Previous default was (1-1/1.01884)
TICKETING_FEE_FIXED = 0.30 # Previous default was 0.8133/1.01884
ABA_USER_NAME = 'Society Name'
ABA_BANK_NAME = 'CBA'
ABA_BANK_CODE = 0
ABA_SRC_BSB = '000-000'
ABA_SRC_ACC = '00000000'
# Download from http://bsb.apca.com.au/
BSB_FILE_PATH = 'sstreasury/BSBDirectoryMay20-290.csv'
PRETIX_API_BASE = 'https://example.com'
PRETIX_API_TOKEN = 'abcdefg'
PRETIX_ORGANIZER = 'societyname'
PRETIX_START_YEAR = '2023' # Ignore events before this year
# Application definition
INSTALLED_APPS = [
'ssmain',
'sstreasury',
'ssmembership',
'sspromotions',
'django.contrib.admin',
'django.contrib.auth',
@ -122,7 +150,6 @@ AUTHENTICATION_BACKENDS = (
)
LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index'
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = None # FIXME
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = None # FIXME
#SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS = {'hd': 'monash.edu'} # To restrict to a particular domain
@ -133,7 +160,7 @@ GOOGLE_CALENDAR_ID = None # FIXME
AWS_KEY_ID = None # FIXME
AWS_SECRET = None # FIXME
AWS_REGION = 'us-east-1'
AWS_SENDER_EMAIL = 'postmaster@example.com' # FIXME
AWS_SENDER_EMAIL = 'example@example.com'
RATELIMIT_KEY = 'ip' # https://django-ratelimit.readthedocs.io/en/stable/keys.html#common-keys e.g. 'header:CF-Connecting-IP'
@ -142,6 +169,7 @@ SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.social_user',
'social_core.pipeline.user.get_username',
'social_core.pipeline.social_auth.associate_by_email',
'social_core.pipeline.user.create_user',
'social_core.pipeline.social_auth.associate_user',
'social_core.pipeline.social_auth.load_extra_data',

View File

@ -32,3 +32,4 @@ if 'ssmembership' in settings.INSTALLED_APPS:
urlpatterns.append(path('', include('ssmain.urls')))
urlpatterns.extend(static(settings.MEDIA_URL, document_root=settings.MEDIA_ROOT))
urlpatterns.extend(static(settings.STATIC_URL, document_root=settings.STATIC_ROOT))

105
ssmain/email.py Normal file
View File

@ -0,0 +1,105 @@
# Society Self-Service
# Copyright © 2018-2023 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import boto3
from botocore.exceptions import ClientError
import premailer
from django.conf import settings
from django.template import loader
from markupsafe import Markup
from selfserv.mdx_urlize import UrlizeExtension
import logging
import markdown
# Debugging
import subprocess
import tempfile
import time
class Emailer:
def __init__(self):
self.client = boto3.client('ses', aws_access_key_id=settings.AWS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET, region_name=settings.AWS_REGION)
self.template = loader.get_template('ssmain/email/rendered.html')
def markdown(self, x):
return markdown.markdown(x, extensions=[UrlizeExtension(), 'fenced_code'])
def boto3_send(self, *args, **kwargs):
for i in range(0, 10):
try:
self.client.send_email(*args, **kwargs)
return
except ClientError as e:
if e['Error']['Code'] == 'Throttling' and e['Error']['Message'] == 'Maximum sending rate exceeded.':
wait_time = max(10*(2**i), 5000)
#self.stdout.write(self.style.NOTICE('Reached maximum sending rate, waiting {} ms'.format(wait_time)))
time.sleep(wait_time/1000)
else:
raise e
raise Exception('Reached maximum number of retries')
def send_raw_mail(self, recipients, subject, content_txt, content_html):
if settings.EMAIL_DEBUG:
with tempfile.NamedTemporaryFile(mode='w', encoding='utf-8', suffix='.mbox') as f:
print('From: sender@example.com\nTo: ' + ','.join(recipients) + '\nSubject: ' + subject + '\nContent-Type: multipart/alternative; boundary=boundary\nMessage-ID: <0@example.com>\n\n--boundary\nContent-Type: text/html; charset=utf-8\n\n' + content_html + '\n--boundary\nContent-Type: text/plain; charset=utf-8\n\n' + content_txt + '\n--boundary', file=f)
subprocess.run(['evolution', f.name])
time.sleep(5)
else:
self.boto3_send(
Destination={
'ToAddresses': recipients,
},
Message={
'Body': {
'Html': {
'Charset': 'utf-8',
'Data': content_html,
},
'Text': {
'Charset': 'utf-8',
'Data': content_txt,
},
},
'Subject': {
'Charset': 'utf-8',
'Data': subject,
},
},
Source='{} <{}>'.format(settings.ORG_NAME, settings.AWS_SENDER_EMAIL),
)
def render_mail(self, template_loc, params={}):
params['baseurl'] = 'https://' + settings.ALLOWED_HOSTS[0]
params['format'] = 'txt'
template = loader.get_template(template_loc)
content_txt = template.render(params).strip().replace('\\*', '*')
params['format'] = 'markdown'
content_markdown = self.markdown(template.render(params))
content_html = self.template.render({'email_content': Markup(content_markdown)})
content_html = premailer.Premailer(content_html, cssutils_logging_level=logging.ERROR, strip_important=False).transform()
return content_txt, content_html
def send_mail(self, recipients, subject, template_loc, params):
content_txt, content_html = self.render_mail(template_loc, params)
self.send_raw_mail(recipients, subject, content_txt, content_html)

View File

@ -21,46 +21,20 @@
{% block head %}
{{ super() }}
<style type="text/css">
.masthead.segment {
{% if request.resolver_match.view_name == 'index' %}
<link rel="stylesheet" type="text/css" href="{{ static('ssmain/main.css') }}">
{% if request.resolver_match.view_name == 'index' %}
<style type="text/css">
.masthead.segment {
min-height: 700px;
{% endif %}
padding: 1em 0em;
}
.masthead h1.ui.header {
{% if request.resolver_match.view_name == 'index' %}
}
.masthead h1.ui.header {
margin-top: 3em;
margin-bottom: 0em;
{% else %}
margin-top: 0.5em;
margin-bottom: 0.5em;
{% endif %}
font-size: 4em;
font-weight: normal;
}
.ui.vertical.stripe {
padding: 8em 0em;
}
.footer.segment {
padding: 5em 0em;
}
.ui.card .description {
line-height: 1.5;
}
textarea {
font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;
}
/* Fix nested selectable tables */
.ui.table.selectable tr > td.selectable:hover {
background: initial !important;
}
</style>
}
</style>
{% endif %}
{% endblock %}
{% block body %}

View File

@ -890,7 +890,7 @@
margin: 30px 0;
Margin: 30px 0; }
pre code {
color: #cacaca; }
color: #000000; }
pre code span.callout {
color: #8a8a8a;
font-weight: bold; }
@ -1070,7 +1070,7 @@
font-size: 12px;
color: #777777; }
p {
p, li {
color: #777777 !important; }
</style>
@ -1091,6 +1091,10 @@
text-align: center;
}
.quote {
margin-left: 16px !important;
}
{% block css %}{% endblock %}
</style>
@ -1424,7 +1428,7 @@
<th>
<p class="text-center footercopy">
{% block footer %}{% endblock %}
&#xA9; Copyright {{ import('datetime').datetime.now().strftime('%Y') }} MUMUS Inc. All Rights Reserved.<br>
&#xA9; Copyright {{ import('datetime').datetime.now().strftime('%Y') }} {{ import('django.conf').settings.ORG_NAME }}. All Rights Reserved.<br>
Design by <a href="https://www.sendwithus.com/resources/templates/meow">SendWithUs</a>.
</p>
</th>

View File

@ -0,0 +1,48 @@
{% extends 'ssmain/email/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
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/>.
#}
{% block content %}
<tr> <!-- main Email content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<td>
{{ email_content }}
</td>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
<tr> <!-- This container adds whitespace gap at the bottom of main content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<th>
&#xA0;
</th>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
{% endblock content %}

View File

@ -0,0 +1,38 @@
# Society Self-Service
# Copyright © 2018-2020 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import User, Group
class Command(BaseCommand):
help = 'Adds the users with the specified emails to the specified group'
def add_arguments(self, parser):
parser.add_argument('group')
parser.add_argument('email', nargs='*')
def handle(self, *args, **options):
group = Group.objects.get(name=options['group'])
for email in options['email']:
try:
user = User.objects.get(email=email)
except User.DoesNotExist:
user = User.objects.create_user(email.split('@')[0], email)
user.save()
user.groups.add(group)

View File

@ -0,0 +1,29 @@
# Society Self-Service
# Copyright © 2018-2020 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand, CommandError
from django.contrib.auth.models import User
from django.db.models import Count
class Command(BaseCommand):
help = 'Finds Users with duplicate emails'
def handle(self, *args, **options):
duplicates = User.objects.values('email').annotate(email_count=Count('email')).filter(email_count__gt=1)
for d in duplicates:
print(d.email)

View File

@ -0,0 +1,71 @@
/*
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
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/>.
*/
.masthead.segment {
padding: 1em 0em;
}
.masthead h1.ui.header {
margin-top: 0.5em;
margin-bottom: 0.5em;
font-size: 4em;
font-weight: normal;
}
.ui.vertical.stripe {
padding: 8em 0em;
}
.footer.segment {
padding: 5em 0em;
}
.ui.card .description {
line-height: 1.5;
}
textarea {
font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;
}
/* Fix nested selectable tables */
.ui.table.selectable tr > td.selectable:hover {
background: initial !important;
}
@media print {
.ui.container > .ui.grid {
display: block; /* Firefox doesn't like printing flex */
}
.ui.container > .ui.grid > [class*="four wide column"] {
display: none;
}
.ui.container > .ui.grid > [class*="twelve wide column"] {
display: block;
width: 100% !important;
}
.masthead.segment {
display: none;
}
.ui.vertical.stripe {
padding: 0;
}
}

View File

@ -1 +0,0 @@
../../../../sspromotions/jinja2/sspromotions/email/base.html

View File

@ -1,35 +0,0 @@
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
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/>.
#}
Dear {{ name }},
From 2019, {{ import('django.conf').settings.ORG_NAME }} is required by law to review its membership annually. You can renew your membership for free by going to the link below or visiting {{ baseurl }}{{ url('mimport_index') }}. The process is very quick and should take less than a minute.
By making sure your details are up to date, you'll also be able to receive personalised weekly emails with relevant news and events from around the Monash Medicine community.
Renew membership for free:
{{ baseurl }}{{ renew_url }}
If you do not renew your membership by **20 March 2019**, your membership will expire, and you will not be able to buy tickets to {{ import('django.conf').settings.ORG_NAME }} events at member prices or run for election within {{ import('django.conf').settings.ORG_NAME }} without paying a membership fee. Please make sure to renew your membership by 20 March 2019 to avoid this.
If you do not want to renew your membership, or you are no longer a Monash medical student, simply ignore this email.
If you encounter any issues renewing your membership, or have any other questions, please contact the Secretary, Yingtong Li, at {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.
Please note that emails are being sent in stages. If other students have not received this email, please let them know that this is normal, and they should receive their email within 7 days. Otherwise, contact {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.

View File

@ -1,65 +0,0 @@
{% extends 'ssmembership/email/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
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/>.
#}
{% block content %}
<tr> <!-- main Email content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<th>
<b><h5>Welcome to {{ import('django.conf').settings.ORG_NAME }}!</h5></b>
<p>Dear {{ name }},</p>
<p>
{% if purchased %}
Thank you for your recent purchase of a {{ import('django.conf').settings.ORG_NAME }} membership.
{% else %}
Thank you for your recent purchase of {{ import('django.conf').settings.ORG_NAME }} tickets or merchandise. Your purchase entitles you to membership of {{ import('django.conf').settings.ORG_NAME }} at no additional cost.
{% endif %}
You can activate your membership now by clicking the button below or visiting <a href="{{ baseurl }}{{ url('monboard_index') }}">{{ baseurl }}{{ url('monboard_index') }}</a>. The process is very quick and should take less than a minute.
</p>
<p>By activating your membership, you'll be able to purchase future tickets at discounted member prices, nominate for election to the {{ import('django.conf').settings.ORG_NAME }} committee, and receive personalised weekly emails with relevant news and events from around the Monash Medicine community.</p>
<div class="button">
<a href="{{ baseurl }}{{ renew_url }}" style="background-color:#f7931d;border:0px solid #f7931d;border-radius:3px;color:#ffffff;display:inline-block;font-family:sans-serif;font-size:16px;font-weight:bold;line-height:35px;text-align:center;text-decoration:none;width:300px;-webkit-text-size-adjust:none;mso-hide:all;">Activate membership now</a>
</div>
<br>
<p>If you do not want to activate your membership, or you are not a Monash medical student, simply ignore this email.</p>
<p>If you encounter any issues activating your membership, or have any other questions, please contact the Secretary, Yingtong Li, at <a href="mailto:{{ import('django.conf').settings.AWS_SENDER_EMAIL }}">{{ import('django.conf').settings.AWS_SENDER_EMAIL }}</a>.</p>
<p style="font-size: x-small;">Please note that emails are being sent in stages. If other students have not received this email, please let them know that this is normal, and they should receive their email within 7 days. Otherwise, contact <a href="mailto:{{ import('django.conf').settings.AWS_SENDER_EMAIL }}">{{ import('django.conf').settings.AWS_SENDER_EMAIL }}</a>.</p>
</th>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
<tr> <!-- This container adds whitespace gap at the bottom of main content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<th>
&#xA0;
</th>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
{% endblock content %}

View File

@ -1,33 +0,0 @@
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
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/>.
#}
Dear {{ name }},
{% if purchased %}Thank you for your recent purchase of a {{ import('django.conf').settings.ORG_NAME }} membership.{% else %}Thank you for your recent purchase of {{ import('django.conf').settings.ORG_NAME }} tickets or merchandise. Your purchase entitles you to membership of {{ import('django.conf').settings.ORG_NAME }} at no additional cost.{% endif %} You can activate your membership now by going to the link below or visiting {{ baseurl }}{{ url('monboard_index') }}. The process is very quick and should take less than a minute.
By activating your membership, you'll be able to purchase future tickets at discounted member prices, nominate for election to the {{ import('django.conf').settings.ORG_NAME }} committee, and receive personalised weekly emails with relevant news and events from around the Monash Medicine community.
Activate membership now:
{{ baseurl }}{{ renew_url }}
If you do not want to activate your membership, or you are not a Monash medical student, simply ignore this email.
If you encounter any issues activating your membership, or have any other questions, please contact the Secretary, Yingtong Li, at {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.
Please note that emails are being sent in stages. If other students have not received this email, please let them know that this is normal, and they should receive their email within 7 days. Otherwise, contact {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.

View File

@ -1,8 +1,8 @@
{% extends 'ssmembership/email/base.html' %}
{% extends 'ssmain/email/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Copyright © 2018-2020 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
@ -28,16 +28,13 @@
<th>
<b><h5>Membership renewal</h5></b>
<p>Dear {{ name }},</p>
<p>From 2019, {{ import('django.conf').settings.ORG_NAME }} is required by law to review its membership annually. You can renew your membership for free by clicking the button below or visiting <a href="{{ baseurl }}{{ url('mimport_index') }}">{{ baseurl }}{{ url('mimport_index') }}</a>. The process is very quick and should take less than a minute.</p>
<p>By making sure your details are up to date, you'll also be able to receive personalised weekly emails with relevant news and events from around the Monash Medicine community.</p>
<div class="button">
<a href="{{ baseurl }}{{ renew_url }}" style="background-color:#f7931d;border:0px solid #f7931d;border-radius:3px;color:#ffffff;display:inline-block;font-family:sans-serif;font-size:16px;font-weight:bold;line-height:35px;text-align:center;text-decoration:none;width:300px;-webkit-text-size-adjust:none;mso-hide:all;">Renew membership for free</a>
<p>{{ import('django.conf').settings.ORG_NAME }} is required by law to review its membership annually. You can renew your membership for free by clicking the button below or visiting <a href="{{ baseurl }}{{ url('renew_index') }}">{{ baseurl }}{{ url('renew_index') }}</a>. The process is very quick and should take less than a minute.</p>
<div class="button" style="margin-bottom:1em;">
<a href="{{ baseurl }}{{ renew_url }}" style="background-color:#f7931d;border:0px solid #f7931d;border-radius:3px;color:#ffffff;display:inline-block;font-family:sans-serif;font-size:16px;font-weight:bold;line-height:35px;text-align:center;text-decoration:none;width:300px;-webkit-text-size-adjust:none;mso-hide:all;">Renew membership now</a>
</div>
<br>
<p>If you do not renew your membership by <b>31 March 2019</b>, your membership will expire, and you will not be able to buy tickets to {{ import('django.conf').settings.ORG_NAME }} events at member prices or run for election within {{ import('django.conf').settings.ORG_NAME }} without paying a membership fee. Please make sure to renew your membership by 31 March 2019 to avoid this.</p>
<p>If you do not renew your membership by <b>31 March {{ import('datetime').datetime.now().strftime('%Y') }}</b>, your membership will expire, and you will not be able to buy tickets to {{ import('django.conf').settings.ORG_NAME }} events at member prices or run for election within {{ import('django.conf').settings.ORG_NAME }} without becoming a member again. If you would like to become a member again after that date, you can either purchase membership for $5, or purchase {{ import('django.conf').settings.ORG_NAME }} merchandise or a ticket to a {{ import('django.conf').settings.ORG_NAME }} event.</p>
<p>If you do not want to renew your membership, or you are no longer a Monash medical student, simply ignore this email.</p>
<p>If you encounter any issues renewing your membership, or have any other questions, please contact the Secretary, Yingtong Li, at <a href="mailto:{{ import('django.conf').settings.AWS_SENDER_EMAIL }}">{{ import('django.conf').settings.AWS_SENDER_EMAIL }}</a>.</p>
<p style="font-size: x-small;">Please note that emails are being sent in stages. If other students have not received this email, please let them know that this is normal, and they should receive their email within 7 days. Otherwise, contact <a href="mailto:{{ import('django.conf').settings.AWS_SENDER_EMAIL }}">{{ import('django.conf').settings.AWS_SENDER_EMAIL }}</a>.</p>
<p>If you encounter any issues renewing your membership, or have any other questions, please contact the Secretary at <a href="mailto:{{ import('django.conf').settings.AWS_SENDER_EMAIL }}">{{ import('django.conf').settings.AWS_SENDER_EMAIL }}</a>.</p>
</th>
<th class="expander"></th>
</tr>

View File

@ -0,0 +1,29 @@
{#
Society Self-Service
Copyright © 2018-2020 Yingtong Li (RunasSudo)
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/>.
#}
Dear {{ name }},
{{ import('django.conf').settings.ORG_NAME }} is required by law to review its membership annually. You can renew your membership for free by going to the link below or visiting {{ baseurl }}{{ url('renew_index') }}. The process is very quick and should take less than a minute.
Renew membership now:
{{ baseurl }}{{ renew_url }}
If you do not renew your membership by **31 March {{ import('datetime').datetime.now().strftime('%Y') }}**, your membership will expire, and you will not be able to buy tickets to MUMUS events at member prices or run for election within MUMUS without becoming a member again. Please make sure to renew your membership by 31 March {{ import('datetime').datetime.now().strftime('%Y') }} to avoid this.
If you do not want to renew your membership, or you are no longer a Monash medical student, simply ignore this email.
If you encounter any issues renewing your membership, or have any other questions, please contact the Secretary at {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.

View File

@ -1,47 +0,0 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
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/>.
#}
{% block title %}Membership renewal{% endblock %}
{% block content %}
<h1>Membership renewal</h1>
<p>To renew an existing membership, please enter your details below:</p>
<form class="ui form" method="POST" action="{{ url('mimport_search') }}">
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>
<div class="nine wide column">
<input type="text" name="student_id" placeholder="28000000">
</div>
</div>
<div class="ui required inline grid field">
<label class="three wide column">Email</label>
<div class="nine wide column">
<input type="text" name="email" placeholder="abcd0001@student.monash.edu">
<div style="margin-top: 1.5em;">Enter the email address that is registered with MUMUS. This is the email which currently receives the MUMUS Bulletin. For most people, this will be your Monash student email; however, some people may have been registered using personal emails.</div>
</div>
</div>
<div class="ui divider"></div>
<div class="ui error message"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Continue">
</form>
{% endblock %}

View File

@ -24,7 +24,6 @@
{% if not member %}
<h1>No membership records</h1>
<p>This email address is not associated with a current membership.</p>
<p><a href="{{ url('mimport_index') }}">Click here</a> to renew an existing membership.</p>
{% else %}
<h1>Membership details</h1>
@ -33,6 +32,10 @@
<label class="three wide column">Member number</label>
<div class="nine wide column">{{ member.id }}</div>
</div>
<div class="ui disabled inline grid field">
<label class="three wide column">Membership type</label>
<div class="nine wide column">{{ member.get_member_type_display() }}</div>
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>

View File

@ -1,122 +0,0 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
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/>.
#}
{% block title %}Membership activation{% endblock %}
{% block content %}
<h1>Membership activation</h1>
{% if not member %}
<p>The details you entered do not match our records, or the membership has already been activated. <a href="{{ url('monboard_index') }}">Click here</a> to try again.</p>
{% else %}
<p>Please check the following details and update them if necessary:</p>
<form class="ui form" method="POST" action="{{ url('monboard_save') }}">
{% if errors %}
<div class="ui visible error message"><ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul></div>
{% endif %}
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>
<input class="nine wide column" type="text" name="student_id" value="{{ member.student_id }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Student email</label>
<input class="nine wide column" type="text" name="email" value="{{ member.email }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">First name</label>
<input class="nine wide column" type="text" name="first_name" value="{{ member.first_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Last name</label>
<input class="nine wide column" type="text" name="last_name" value="{{ member.last_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Phone number</label>
<input class="nine wide column" type="text" name="phone" value="{{ member.phone }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Year level</label>
<select id="drop_year" class="ui dropdown eleven wide column" name="year">
<option value="">Year level</option>
{% for year in years %}
<option value="{{ year[0] }}">{{ year[1] }}</option>
{% endfor %}
</select>
</div>
<div class="ui required inline grid field">
<label class="three wide column">MSA membership</label>
<select id="drop_msa" class="ui dropdown eleven wide column" name="is_msa">
<option value="">MSA membership</option>
<option value="0">No, I am not an MSA member</option>
<option value="1">Yes, I am an MSA member</option>
</select>
</div>
<div class="ui divider"></div>
<h2>MUMUS Mail</h2>
<div class="ui required inline grid field">
<label class="three wide column">Opt-in/out</label>
<select id="drop_bulletin_subscribe" class="ui dropdown eleven wide column" name="bulletin_subscribe">
<option value="0">Do not send me MUMUS Mail (not recommended)</option>
<option value="1" selected>Keep me updated with MUMUS Mail</option>
</select>
</div>
<div class="ui inline grid field">
<label class="three wide column">Subscriptions</label>
<div class="eleven wide column">
{% for group in import('sspromotions.models').Group.objects.all() %}
{% if group.subscribable %}
<div class="field" style="display: inline; margin-right: 1em;">
<div class="ui checkbox">
<input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}">
<label for="bulletin_group_{{ group.id }}">{{ group.name }}</label>
</div>
</div>
{% endif %}
{% endfor %}
<p style="margin-top: 0.5em;">MUMUS Mail is personalised for you. Choose the groups that you would like to see first in each edition of MUMUS Mail.</p>
</div>
</div>
<div class="ui divider"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input type="hidden" name="email_orig" value="{{ email_orig }}">
<input type="hidden" name="sig" value="{{ sig }}">
<input class="ui primary button" type="submit" name='submit' value="Continue">
</form>
{% endif %}
{% endblock %}
{% block script %}
{{ super() }}
{% if member %}
<script>
$('.ui.dropdown').dropdown();
$('#drop_year').dropdown('set selected', '{{ member.year }}');
$('#drop_msa').dropdown('set selected', '{{ 1 if member.is_msa else 0 }}');
</script>
{% endif %}
{% endblock %}

View File

@ -2,7 +2,7 @@
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Copyright © 2018-2020 Yingtong Li (RunasSudo)
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

View File

@ -2,7 +2,7 @@
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Copyright © 2018-2020 Yingtong Li (RunasSudo)
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
@ -18,14 +18,14 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Membership activation{% endblock %}
{% block title %}Membership renewal{% endblock %}
{% block content %}
<h1>Membership activation</h1>
<h1>Membership renewal</h1>
<p>To activate a new membership, please enter your details below:</p>
<p>To renew your membership, please enter your details below:</p>
<form class="ui form" method="POST" action="{{ url('monboard_search') }}">
<form class="ui form" method="POST" action="{{ url('renew_search') }}">
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>
<div class="nine wide column">
@ -36,7 +36,7 @@
<label class="three wide column">Email</label>
<div class="nine wide column">
<input type="text" name="email" placeholder="abcd0001@student.monash.edu">
<div style="margin-top: 1.5em;">Enter the email address that is registered with MUMUS. This is the email which received the purchased ticket/receipt.</div>
<div style="margin-top: 1.5em;">Enter the email address that is registered with MUMUS. This is the email which received the renewal notice.</div>
</div>
</div>
<div class="ui divider"></div>

View File

@ -2,7 +2,7 @@
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Copyright © 2018-2022 Yingtong Li (RunasSudo)
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
@ -24,11 +24,11 @@
<h1>Membership renewal</h1>
{% if not member %}
<p>The details you entered do not match our records, or the membership has already been renewed. <a href="{{ url('mimport_index') }}">Click here</a> to try again.</p>
<p>The details you entered do not match our records. <a href="{{ url('renew_index') }}">Click here</a> to try again.</p>
{% else %}
<p>Please check the following details and update them if necessary:</p>
<form class="ui form" method="POST" action="{{ url('mimport_save') }}">
<form class="ui form" method="POST" action="{{ url('renew_save') }}">
{% if errors %}
<div class="ui visible error message"><ul>
{% for error in errors %}
@ -40,9 +40,13 @@
<label class="three wide column">Student ID</label>
<input class="nine wide column" type="text" name="student_id" value="{{ member.student_id }}">
</div>
<div class="ui required inline grid field">
<div class="ui {% if member.email.endswith('@student.monash.edu') %}disabled{% else %}required{% endif %} inline grid field">
<label class="three wide column">Student email</label>
<input class="nine wide column" type="text" name="email" value="{{ member.email }}">
{% if member.email.endswith('@student.monash.edu') %}
<div class="nine wide column">{{ member.email }}</div>
{% else %}
<input class="nine wide column" type="text" name="email" value="{{ member.email }}">
{% endif %}
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
@ -75,32 +79,34 @@
<option value="1">Yes, I am an MSA member</option>
</select>
</div>
<div class="ui divider"></div>
<h2>MUMUS Mail</h2>
{% if 'sspromotions' in settings.INSTALLED_APPS %}
<div class="ui divider"></div>
<h2>MUMUS Mail</h2>
<div class="ui required inline grid field">
<label class="three wide column">Opt-in/out</label>
<select id="drop_bulletin_subscribe" class="ui dropdown eleven wide column" name="bulletin_subscribe">
<option value="0">Do not send me MUMUS Mail (not recommended)</option>
<option value="1" selected>Keep me updated with MUMUS Mail</option>
</select>
</div>
<div class="ui inline grid field">
<label class="three wide column">Subscriptions</label>
<div class="eleven wide column">
{% for group in import('sspromotions.models').Group.objects.all() %}
{% if group.subscribable %}
<div class="field" style="display: inline; margin-right: 1em;">
<div class="ui checkbox">
<input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}">
<label for="bulletin_group_{{ group.id }}">{{ group.name }}</label>
</div>
</div>
{% endif %}
{% endfor %}
<p style="margin-top: 0.5em;">MUMUS Mail is personalised for you. Choose the groups that you would like to see first in each edition of MUMUS Mail.</p>
<div class="ui required inline grid field">
<label class="three wide column">Opt-in/out</label>
<select id="drop_bulletin_subscribe" class="ui dropdown eleven wide column" name="bulletin_subscribe">
<option value="0">Do not send me MUMUS Mail (not recommended)</option>
<option value="1" selected>Keep me updated with MUMUS Mail</option>
</select>
</div>
</div>
<div class="ui inline grid field">
<label class="three wide column">Subscriptions</label>
<div class="eleven wide column">
{% for group in import('sspromotions.models').Group.objects.all() %}
{% if group.subscribable %}
<div class="field" style="display: inline; margin-right: 1em;">
<div class="ui checkbox">
<input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}" checked>
<label for="bulletin_group_{{ group.id }}">{{ group.name }}</label>
</div>
</div>
{% endif %}
{% endfor %}
<p style="margin-top: 0.5em;">MUMUS Mail is personalised for you. Choose the groups that you would like to see first in each edition of MUMUS Mail.</p>
</div>
</div>
{% endif %}
<div class="ui divider"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input type="hidden" name="email_orig" value="{{ email_orig }}">

View File

@ -18,12 +18,12 @@
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Membership activation complete{% endblock %}
{% block title %}Subscription complete{% endblock %}
{% block content %}
<h1>Membership activation complete</h1>
<h1>Subscription complete</h1>
<p>Your membership activation has been successfully processed.</p>
<p>Your associate membership and email subscription have been successfully processed.</p>
<p>You can view and edit your membership details by <a href="{{ url('membership') }}">logging in</a>.</p>
{% endblock %}

View File

@ -0,0 +1,116 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2022 Yingtong Li (RunasSudo)
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/>.
#}
{% block title %}Subscribe to {{ settings.ORG_NAME }} Mail{% endblock %}
{% block content %}
<h1>Subscribe to {{ settings.ORG_NAME }} Mail</h1>
<p>To become an associate member of {{ settings.ORG_NAME }} and subscribe to {{ settings.ORG_NAME }} Mail, please enter your details below:</p>
<form class="ui form" method="POST" action="{{ url('signup_save') }}">
{% if errors %}
<div class="ui visible error message"><ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul></div>
{% endif %}
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>
<input class="nine wide column" type="text" name="student_id" value="{{ member.student_id }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Student email</label>
<input class="nine wide column" type="text" name="email" value="{{ member.email }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">First name</label>
<input class="nine wide column" type="text" name="first_name" value="{{ member.first_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Last name</label>
<input class="nine wide column" type="text" name="last_name" value="{{ member.last_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Phone number</label>
<input class="nine wide column" type="text" name="phone" value="{{ member.phone }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Year level</label>
<select id="drop_year" class="ui dropdown eleven wide column" name="year">
<option value="">Year level</option>
{% for year in years %}
<option value="{{ year[0] }}">{{ year[1] }}</option>
{% endfor %}
</select>
</div>
<div class="ui required inline grid field">
<label class="three wide column">MSA membership</label>
<select id="drop_msa" class="ui dropdown eleven wide column" name="is_msa">
<option value="">MSA membership</option>
<option value="0">No, I am not an MSA member</option>
<option value="1">Yes, I am an MSA member</option>
</select>
</div>
{% if 'sspromotions' in settings.INSTALLED_APPS %}
<div class="ui divider"></div>
<h2>MUMUS Mail</h2>
<div class="ui required inline grid field">
<label class="three wide column">Opt-in/out</label>
<select id="drop_bulletin_subscribe" class="ui dropdown eleven wide column" name="bulletin_subscribe">
<option value="0">Do not send me MUMUS Mail (not recommended)</option>
<option value="1" selected>Keep me updated with MUMUS Mail</option>
</select>
</div>
<div class="ui inline grid field">
<label class="three wide column">Subscriptions</label>
<div class="eleven wide column">
{% for group in import('sspromotions.models').Group.objects.all() %}
{% if group.subscribable %}
<div class="field" style="display: inline; margin-right: 1em;">
<div class="ui checkbox">
<input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}">
<label for="bulletin_group_{{ group.id }}">{{ group.name }}</label>
</div>
</div>
{% endif %}
{% endfor %}
<p style="margin-top: 0.5em;">MUMUS Mail is personalised for you. Choose the groups that you would like to see first in each edition of MUMUS Mail.</p>
</div>
</div>
{% endif %}
<div class="ui divider"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Subscribe">
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script>
$('.ui.dropdown').dropdown();
$('#drop_year').dropdown('set selected', '{{ member.year }}');
$('#drop_msa').dropdown('set selected', '{{ 1 if member.is_msa else 0 }}');
</script>
{% endblock %}

View File

@ -0,0 +1,29 @@
# Society Self-Service
# Copyright © 2018-2020 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand, CommandError
from ssmembership.models import Member
from django.db.models import Count
class Command(BaseCommand):
help = 'Finds members with duplicate emails'
def handle(self, *args, **options):
duplicates = Member.objects.values('email').annotate(email_count=Count('email')).filter(email_count__gt=1)
for d in duplicates:
print(d.email)

View File

@ -0,0 +1,75 @@
# Society Self-Service
# Copyright © 2018-2022 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from ssmain.email import Emailer
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from django.template import loader
from django.urls import reverse
from django.utils import timezone
from ssmembership import models
import hmac
import logging
import premailer
import time
import urllib.parse
class Command(BaseCommand):
help = 'Send emails for membership renewal'
def add_arguments(self, parser):
parser.add_argument('ids', nargs='*', type=int, help='Members with ID numbers equal to these values will be emailed')
parser.add_argument('--all', action='store_true', help='Email all members rather than only specified IDs')
#parser.add_argument('--render', action='store_true', help='Render to stdout instead of sending emails')
def handle(self, *args, **options):
today = timezone.localtime(timezone.now()).date()
template_html = loader.get_template('ssmembership/email/renew.html')
template_txt = loader.get_template('ssmembership/email/renew.txt')
if options['all']:
members = models.Member.objects.all()
elif len(options['ids']) > 0:
members = models.Member.objects.filter(id__in=options['ids'])
else:
raise Exception('Must provide IDs or specify --all')
emailer = Emailer()
for member in members:
if member.member_type != 1 or member.expires < today or member.expires > today.replace(month=12, day=31):
self.stdout.write('Skipping {} at {}'.format(member.id, member.email))
continue
self.stdout.write('Emailing {} at {}'.format(member.id, member.email))
sig = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member.email.encode('utf-8'), 'sha256').hexdigest()
renew_url = reverse('renew_signed') + '?' + urllib.parse.urlencode({'email': member.email, 'sig': sig})
template_args = {
'name': member.first_name.strip() + ' ' + member.last_name.strip(),
'renew_url': renew_url,
'baseurl': 'https://' + settings.ALLOWED_HOSTS[0]
}
content_html = premailer.Premailer(template_html.render(template_args), cssutils_logging_level=logging.ERROR).transform()
content_txt = template_txt.render(template_args)
emailer.send_raw_mail([member.email], '{} membership renewal'.format(settings.ORG_NAME), content_txt, content_html)

View File

@ -1,107 +0,0 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# 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/>.
import boto3
from botocore.exceptions import ClientError
import premailer
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from django.template import loader
from django.urls import reverse
from ssmembership.mimport import get_members
import hmac
import logging
import time
import urllib.parse
class Command(BaseCommand):
help = 'Send emails for membership import (renewal)'
def add_arguments(self, parser):
parser.add_argument('ids', nargs='*', type=int, help='Members with ID numbers equal to these values will be emailed (default all)')
parser.add_argument('--render', action='store_true', help='Render to stdout instead of sending emails')
def handle(self, *args, **options):
template_html = loader.get_template('ssmembership/email/import.html')
template_txt = loader.get_template('ssmembership/email/import.txt')
members = get_members()
if len(options['ids']) > 0:
members = [member for member in members if member[0] in options['ids']]
else:
raise Exception('Must provide IDs')
client = boto3.client('ses', aws_access_key_id=settings.AWS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET, region_name=settings.AWS_REGION)
def send_mail(**kwargs):
for i in range(0, 10):
try:
client.send_email(**kwargs)
return
except ClientError as e:
if e['Error']['Code'] == 'Throttling' and e['Error']['Message'] == 'Maximum sending rate exceeded.':
wait_time = max(10*(2**i), 5000)
self.stdout.write(self.style.NOTICE('Reached maximum sending rate, waiting {} ms'.format(wait_time)))
time.sleep(wait_time/1000)
else:
raise e
raise Exception('Reached maximum number of retries')
for member in members:
#_id, student_id, email, first_name, last_name, year, is_msa, phone, date
sig = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member[2].encode('utf-8'), 'sha256').hexdigest()
renew_url = reverse('mimport_signed') + '?' + urllib.parse.urlencode({'email': member[2], 'sig': sig})
template_args = {
'name': member[3].strip() + ' ' + member[4].strip(),
'renew_url': renew_url,
'baseurl': 'https://' + settings.ALLOWED_HOSTS[0]
}
content_html = premailer.Premailer(template_html.render(template_args), cssutils_logging_level=logging.ERROR).transform()
content_txt = template_txt.render(template_args)
if options['render']:
self.stdout.write(content_html)
else:
self.stdout.write('Emailing {} at {}'.format(member[0], member[2]))
send_mail(
Destination={
'ToAddresses': [member[2]],
},
Message={
'Body': {
'Html': {
'Charset': 'utf-8',
'Data': content_html,
},
'Text': {
'Charset': 'utf-8',
'Data': content_txt,
},
},
'Subject': {
'Charset': 'utf-8',
'Data': settings.ORG_NAME + ' membership renewal',
},
},
Source='{} <{}>'.format(settings.ORG_NAME, settings.AWS_SENDER_EMAIL),
)

View File

@ -0,0 +1,47 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from ssmain.email import Emailer
import ssmembership.models
class Command(BaseCommand):
help = 'Send Markdown emails'
def add_arguments(self, parser):
parser.add_argument('template', help='Template name')
parser.add_argument('subject', help='Email subject')
parser.add_argument('ids', nargs='*', type=int, help='Members with ID numbers equal to these values will be emailed (default all)')
def handle(self, *args, **options):
today = timezone.localtime(timezone.now()).date()
members = ssmembership.models.Member.objects.all()
if len(options['ids']) > 0:
members = [member for member in members if member.id in options['ids']]
else:
raise Exception('Must provide IDs')
emailer = Emailer()
for member in members:
if member.member_type != 1 or member.expires < today:
self.stdout.write('Skipping {} at {}'.format(member.id, member.email))
continue
self.stdout.write('Emailing {} at {}'.format(member.id, member.email))
emailer.send_mail([member.email], options['subject'], 'ssmembership/email/' + options['template'] + '.md', {})

View File

@ -1,112 +0,0 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# 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/>.
import boto3
from botocore.exceptions import ClientError
import premailer
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from django.template import loader
from django.urls import reverse
from ssmembership.monboard import get_members, set_emailed_by_email
import hmac
import logging
import time
import urllib.parse
class Command(BaseCommand):
help = 'Send emails for membership onboarding'
def add_arguments(self, parser):
parser.add_argument('ids', nargs='*', type=int, help='Members with ID numbers equal to these values will be emailed (default all)')
parser.add_argument('--render', action='store_true', help='Render to stdout instead of sending emails')
def handle(self, *args, **options):
template_html = loader.get_template('ssmembership/email/onboard.html')
template_txt = loader.get_template('ssmembership/email/onboard.txt')
members = get_members()
if len(options['ids']) > 0:
members = [member for member in members if member[0] in options['ids']]
else:
raise Exception('Must provide IDs')
client = boto3.client('ses', aws_access_key_id=settings.AWS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET, region_name=settings.AWS_REGION)
def send_mail(**kwargs):
for i in range(0, 10):
try:
client.send_email(**kwargs)
return
except ClientError as e:
if e['Error']['Code'] == 'Throttling' and e['Error']['Message'] == 'Maximum sending rate exceeded.':
wait_time = max(10*(2**i), 5000)
self.stdout.write(self.style.NOTICE('Reached maximum sending rate, waiting {} ms'.format(wait_time)))
time.sleep(wait_time/1000)
else:
raise e
raise Exception('Reached maximum number of retries')
for member in members:
#_id, student_id, email, first_name, last_name, year, is_msa, phone, date, purchased, imported, emailed
if member[10] or member[11]:
continue
sig = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member[2].encode('utf-8'), 'sha256').hexdigest()
renew_url = reverse('monboard_signed') + '?' + urllib.parse.urlencode({'email': member[2], 'sig': sig})
template_args = {
'name': member[3].strip() + ' ' + member[4].strip(),
'renew_url': renew_url,
'baseurl': 'https://' + settings.ALLOWED_HOSTS[0],
'purchased': member[10]
}
content_html = premailer.Premailer(template_html.render(template_args), cssutils_logging_level=logging.ERROR).transform()
content_txt = template_txt.render(template_args)
if options['render']:
self.stdout.write('Content-Type: multipart/alternative; boundary=boundary\n\n--boundary\nContent-Type: text/html; charset=utf-8\n\n' + content_html + '\n--boundary\nContent-Type: text/plain; charset=utf-8\n\n' + content_txt + '\n--boundary')
else:
self.stdout.write('Emailing {} at {}'.format(member[0], member[2]))
send_mail(
Destination={
'ToAddresses': [member[2]],
},
Message={
'Body': {
'Html': {
'Charset': 'utf-8',
'Data': content_html,
},
'Text': {
'Charset': 'utf-8',
'Data': content_txt,
},
},
'Subject': {
'Charset': 'utf-8',
'Data': 'Activate your ' + settings.ORG_NAME + ' membership',
},
},
Source='{} <{}>'.format(settings.ORG_NAME, settings.AWS_SENDER_EMAIL),
)
set_emailed_by_email(member[2])

View File

@ -1,70 +0,0 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# 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/>.
import sqlite3
from django.utils import timezone
from . import models
import datetime
def get_members():
conn = sqlite3.connect('file:members.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members')
result = cur.fetchall()
conn.close()
return result
def by_email(email):
conn = sqlite3.connect('file:members.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members WHERE email=?', (email,))
result = cur.fetchone()
conn.close()
if not result:
return None
member = models.Member()
# id, student_id, email, first_name, last_name, year, is_msa, phone, date
member.student_id = result[1]
member.email = result[2]
member.first_name = result[3]
member.last_name = result[4]
member.year = {'Year A': 0, 'Year 1': 1, 'Year 2': 2, 'Year 3B': 3, 'Year 4C': 4, 'Year 5D': 5, 'BMedSci': 97, 'PhD': 98, 'Intermission': 99}[result[5]]
member.is_msa = result[6]
member.phone = result[7]
# Calculate expiration date
member.expires = timezone.now().date().replace(month=3, day=31)
member.expires = member.expires.replace(year=member.expires.year+1)
if member.expires < timezone.now().date(): # Add 1 year if after Mar 31, else add 2 years
member.expires = member.expires.replace(year=member.expires.year+1)
return member
def delete_by_email(email):
conn = sqlite3.connect('file:members.db', uri=True)
cur = conn.cursor()
cur.execute('DELETE FROM members WHERE email=?', (email,))
conn.commit()
conn.close()

View File

@ -42,6 +42,13 @@ class Member(models.Model):
phone = models.CharField(max_length=20)
MEMBER_TYPES = (
(1, 'Ordinary Member'),
(2, 'Associate Member'),
(3, 'Honorary Life Member'),
)
member_type = models.IntegerField(choices=MEMBER_TYPES)
expires = models.DateField()
class Meta:

View File

@ -1,77 +0,0 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# 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/>.
import sqlite3
from django.utils import timezone
from . import models
import datetime
def get_members():
conn = sqlite3.connect('file:onboards.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members')
result = cur.fetchall()
conn.close()
return result
def by_email(email):
conn = sqlite3.connect('file:onboards.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members WHERE email=? AND imported=0', (email,))
result = cur.fetchone()
conn.close()
if not result:
return None
member = models.Member()
# id, student_id, email, first_name, last_name, year, is_msa, phone, date, purchased, imported
member.student_id = result[1]
member.email = result[2]
member.first_name = result[3]
member.last_name = result[4]
member.year = {'Year A': 0, 'Year 1': 1, 'Year 2': 2, 'Year 3B': 3, 'Year 4C': 4, 'Year 5D': 5, 'BMedSci': 97, 'PhD': 98, 'Intermission': 99}[result[5]]
member.is_msa = result[6]
member.phone = result[7]
# Calculate expiration date
member.expires = timezone.now().date().replace(month=3, day=31)
member.expires = member.expires.replace(year=member.expires.year+1)
if member.expires < timezone.now().date(): # Add 1 year if after Mar 31, else add 2 years
member.expires = member.expires.replace(year=member.expires.year+1)
return member
def delete_by_email(email):
conn = sqlite3.connect('file:onboards.db', uri=True)
cur = conn.cursor()
cur.execute('UPDATE members SET imported=1 WHERE email=?', (email,))
conn.commit()
conn.close()
def set_emailed_by_email(email):
conn = sqlite3.connect('file:onboards.db', uri=True)
cur = conn.cursor()
cur.execute('UPDATE members SET emailed=1 WHERE email=?', (email,))
conn.commit()
conn.close()

View File

@ -20,12 +20,10 @@ from . import views
urlpatterns = [
path('', views.index, name='membership'),
path('import/', views.import_index, name='mimport_index'),
path('import/signed', views.import_signed, name='mimport_signed'),
path('import/search', views.import_search, name='mimport_search'),
path('import/save', views.import_save, name='mimport_save'),
path('onboard/', views.onboard_index, name='monboard_index'),
path('onboard/signed', views.onboard_signed, name='monboard_signed'),
path('onboard/search', views.onboard_search, name='monboard_search'),
path('onboard/save', views.onboard_save, name='monboard_save'),
path('renew/', views.renew_index, name='renew_index'),
path('renew/signed', views.renew_signed, name='renew_signed'),
path('renew/search', views.renew_search, name='renew_search'),
path('renew/save', views.renew_save, name='renew_save'),
path('signup/', views.signup_index, name='signup_index'),
path('signup/save', views.signup_save, name='signup_save'),
]

View File

@ -1,5 +1,5 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
# Copyright © 2018-2022 Yingtong Li (RunasSudo)
#
# 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
@ -23,9 +23,8 @@ from django.db import transaction
from django.http import HttpResponse
from django.shortcuts import render, redirect
from django.urls import reverse
from django.utils import timezone
from . import mimport
from . import monboard
from . import models
import hmac
@ -71,10 +70,10 @@ def index(request):
return render(request, 'ssmembership/index.html', {'member': member, 'years': models.Member.YEARS})
def import_index(request):
return render(request, 'ssmembership/import/index.html')
def renew_index(request):
return render(request, 'ssmembership/renew/index.html')
def import_signed(request):
def renew_signed(request):
if 'email' not in request.GET:
return HttpResponse('Expected an email address', status=400)
if 'sig' not in request.GET:
@ -84,8 +83,8 @@ def import_signed(request):
if not hmac.compare_digest(sig_expected, request.GET['sig']):
return HttpResponse('Invalid signature', status=403)
member = mimport.by_email(request.GET['email'])
return render(request, 'ssmembership/import/review.html', {
member = models.Member.objects.get(email=request.GET['email'])
return render(request, 'ssmembership/renew/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': member.email if member else None,
@ -93,54 +92,60 @@ def import_signed(request):
})
@ratelimit(key=settings.RATELIMIT_KEY, rate='100/h')
def import_search(request):
def renew_search(request):
if request.method != 'POST':
return redirect(reverse('import_index'))
return redirect(reverse('renew_index'))
if request.limited:
return HttpResponse('Too many requests', status=429)
member = mimport.by_email(request.POST['email'])
if member and member.student_id != request.POST['student_id']:
try:
member = models.Member.objects.get(email=request.POST['email'])
if member.student_id != request.POST['student_id']:
member = None
except models.Member.DoesNotExist:
member = None
return render(request, 'ssmembership/import/review.html', {
return render(request, 'ssmembership/renew/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': member.email if member else None,
'sig': hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member.email.encode('utf-8'), 'sha256').hexdigest() if member else None
})
def import_save(request):
def renew_save(request):
if request.method != 'POST':
return redirect(reverse('import_index'))
return redirect(reverse('renew_index'))
sig_expected = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), request.POST['email_orig'].encode('utf-8'), 'sha256').hexdigest()
if not hmac.compare_digest(sig_expected, request.POST['sig']):
return HttpResponse('Invalid signature', status=403)
member = mimport.by_email(request.POST['email_orig'])
member = models.Member.objects.get(email=request.POST['email_orig'])
if not member:
return render(request, 'ssmembership/import/review.html', {
'member': member
})
member.student_id = request.POST['student_id']
member.email = request.POST['email']
member.first_name = request.POST['first_name']
member.last_name = request.POST['last_name']
member.phone = request.POST['phone']
member.student_id = request.POST['student_id'].strip()
if not request.POST['email_orig'].endswith('@student.monash.edu'):
member.email = request.POST['email'].strip()
member.first_name = request.POST['first_name'].strip()
member.last_name = request.POST['last_name'].strip()
member.phone = request.POST['phone'].strip()
member.year = int(request.POST['year'])
member.is_msa = True if request.POST['is_msa'] == '1' else '0'
# Calculate expiration date
member.expires = timezone.localtime(timezone.now()).date().replace(month=3, day=31)
member.expires = member.expires.replace(year=member.expires.year+1)
if member.expires < timezone.localtime(timezone.now()).date(): # Add 1 year if after Mar 31, else add 2 years
member.expires = member.expires.replace(year=member.expires.year+1)
errors = member.validation_problems()
if models.Member.objects.filter(email=request.POST['email']).count() > 0:
if not request.POST['email_orig'].endswith('@student.monash.edu') and models.Member.objects.filter(email=member.email).count() > 0:
errors.append('Member with this email already exists')
if len(errors) > 0:
return render(request, 'ssmembership/import/review.html', {
return render(request, 'ssmembership/renew/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': request.POST['email_orig'],
@ -152,72 +157,28 @@ def import_save(request):
member.save()
# Update bulletin
import sspromotions.models
sspromotions.models.BulletinSubscription.set_member_subscribed(member, True if request.POST['bulletin_subscribe'] == '1' else False)
for group in sspromotions.models.Group.objects.filter(subscribable=True).all():
if ('bulletin_group_' + str(group.id)) in request.POST and request.POST['bulletin_group_' + str(group.id)]:
group.subscribe_member(member, True)
else:
group.subscribe_member(member, False)
if 'sspromotions' in settings.INSTALLED_APPS:
import sspromotions.models
sspromotions.models.BulletinSubscription.set_member_subscribed(member, True if request.POST['bulletin_subscribe'] == '1' else False)
for group in sspromotions.models.Group.objects.filter(subscribable=True).all():
if ('bulletin_group_' + str(group.id)) in request.POST and request.POST['bulletin_group_' + str(group.id)]:
group.subscribe_member(member, True)
else:
group.subscribe_member(member, False)
mimport.delete_by_email(request.POST['email_orig'])
return render(request, 'ssmembership/import/complete.html')
return render(request, 'ssmembership/renew/complete.html')
def onboard_index(request):
return render(request, 'ssmembership/onboard/index.html')
def onboard_signed(request):
if 'email' not in request.GET:
return HttpResponse('Expected an email address', status=400)
if 'sig' not in request.GET:
return HttpResponse('Expected a signature parameter', status=400)
sig_expected = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), request.GET['email'].encode('utf-8'), 'sha256').hexdigest()
if not hmac.compare_digest(sig_expected, request.GET['sig']):
return HttpResponse('Invalid signature', status=403)
member = monboard.by_email(request.GET['email'])
return render(request, 'ssmembership/onboard/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': member.email if member else None,
'sig': sig_expected
def signup_index(request):
return render(request, 'ssmembership/signup/index.html', {
'member': models.Member(),
'years': models.Member.YEARS
})
@ratelimit(key=settings.RATELIMIT_KEY, rate='100/h')
def onboard_search(request):
def signup_save(request):
if request.method != 'POST':
return redirect(reverse('onboard_index'))
if request.limited:
return HttpResponse('Too many requests', status=429)
member = monboard.by_email(request.POST['email'])
if member and member.student_id != request.POST['student_id']:
member = None
return render(request, 'ssmembership/onboard/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': member.email if member else None,
'sig': hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member.email.encode('utf-8'), 'sha256').hexdigest() if member else None
})
def onboard_save(request):
if request.method != 'POST':
return redirect(reverse('onboard_index'))
sig_expected = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), request.POST['email_orig'].encode('utf-8'), 'sha256').hexdigest()
if not hmac.compare_digest(sig_expected, request.POST['sig']):
return HttpResponse('Invalid signature', status=403)
member = monboard.by_email(request.POST['email_orig'])
if not member:
return render(request, 'ssmembership/onboard/review.html', {
'member': member
})
return redirect(reverse('signup_index'))
member = models.Member()
member.student_id = request.POST['student_id']
member.email = request.POST['email']
member.first_name = request.POST['first_name']
@ -225,6 +186,13 @@ def onboard_save(request):
member.phone = request.POST['phone']
member.year = int(request.POST['year'])
member.is_msa = True if request.POST['is_msa'] == '1' else '0'
member.member_type = 2 # Associate Member
# Calculate expiration date
member.expires = timezone.localtime(timezone.now()).date().replace(month=3, day=20)
member.expires = member.expires.replace(year=member.expires.year+1)
if member.expires < timezone.localtime(timezone.now()).date(): # Add 1 year if after Mar 20, else add 2 years
member.expires = member.expires.replace(year=member.expires.year+1)
errors = member.validation_problems()
@ -232,11 +200,9 @@ def onboard_save(request):
errors.append('Member with this email already exists')
if len(errors) > 0:
return render(request, 'ssmembership/onboard/review.html', {
return render(request, 'ssmembership/signup/index.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': request.POST['email_orig'],
'sig': request.POST['sig'],
'errors': errors
})
@ -244,13 +210,13 @@ def onboard_save(request):
member.save()
# Update bulletin
import sspromotions.models
sspromotions.models.BulletinSubscription.set_member_subscribed(member, True if request.POST['bulletin_subscribe'] == '1' else False)
for group in sspromotions.models.Group.objects.filter(subscribable=True).all():
if ('bulletin_group_' + str(group.id)) in request.POST and request.POST['bulletin_group_' + str(group.id)]:
group.subscribe_member(member, True)
else:
group.subscribe_member(member, False)
if 'sspromotions' in settings.INSTALLED_APPS:
import sspromotions.models
sspromotions.models.BulletinSubscription.set_member_subscribed(member, True if request.POST['bulletin_subscribe'] == '1' else False)
for group in sspromotions.models.Group.objects.filter(subscribable=True).all():
if ('bulletin_group_' + str(group.id)) in request.POST and request.POST['bulletin_group_' + str(group.id)]:
group.subscribe_member(member, True)
else:
group.subscribe_member(member, False)
monboard.delete_by_email(request.POST['email_orig'])
return render(request, 'ssmembership/onboard/complete.html')
return render(request, 'ssmembership/signup/complete.html')

View File

@ -73,7 +73,12 @@
</div>
<div class="ui inline grid field">
<label class="three wide column">Image</label>
<input class="eleven wide column" type="file" name="image" value="{{ item.image or '' }}">
<div class="eleven wide column">
{% if item.image %}
<a href="{{ MEDIA_URL }}{{ item.image.name }}">{{ item.image.name.split('/')[-1] }}</a>
{% endif %}
<input type="file" name="image">
</div>
</div>
<div class="ui divider"></div>
<div class="ui inline grid field">
@ -95,6 +100,9 @@
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Save">
<input class="ui button" type="submit" name='submit' value="Save and continue editing">
{% if request.resolver_match.url_name == 'bulletin_edit' %}
<input class="ui right floated red button" type="submit" name='submit' value="Delete" onclick="return confirm('Are you sure you want to delete this bulletin item? This action is IRREVERSIBLE.');">
{% endif %}
</form>
{% endblock %}

View File

@ -2,7 +2,7 @@
{#
Society Self-Service
Copyright © 2018 Yingtong Li (RunasSudo)
Copyright © 2018-2019 Yingtong Li (RunasSudo)
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
@ -24,9 +24,9 @@
<table class="ui selectable celled table">
<thead>
<tr>
<th class="four wide">Title</th>
<th class="five wide">Title</th>
<th class="ten wide">Content</th>
<th class="two wide">Actions</th>
<th class="one wide">Edit</th>
</tr>
</thead>
<tbody>
@ -34,9 +34,8 @@
<tr>
<td class="selectable"><a href="{{ url('bulletin_edit', kwargs={'id': item.id}) }}">{{ item.title }}</a></td>
<td>{{ item.content|markdown }}</td>
<td class="selectable">
<a href="{{ url('bulletin_edit', kwargs={'id': item.id}) }}" class="ui tiny primary icon button" style="margin: 0.8em 0 0.8em 0.8em;"><i class="edit icon"></i></a>
<a href="{{ url('bulletin_delete', kwargs={'id': item.id}) }}" onclick="return confirm('Are you sure you want to delete this bulletin item?');" class="ui tiny red icon button" style="margin: 0.8em 0 0.8em 0.8em;"><i class="trash icon"></i></a>
<td>
<a href="{{ url('bulletin_edit', kwargs={'id': item.id}) }}" class="ui tiny primary icon button"><i class="edit icon"></i></a>
</td>
</tr>
{% endfor %}
@ -47,7 +46,7 @@
{% block maincontent %}
<h1>Your bulletin items</h1>
{% if not items_past and not items_upcoming and not items_future %}
{% if not items_past_page and not items_upcoming and not items_future %}
<p>You have no bulletin items to view. To create a bulletin item, click <a href="{{ url('bulletin_new') }}">Create new bulletin item</a>.</p>
{% endif %}
@ -63,10 +62,22 @@
{{ listitems(items_future) }}
{% endif %}
{% if items_past %}
{% if items_past_page %}
<h2>Past bulletin items</h2>
{{ listitems(items_past) }}
{{ listitems(items_past_page.object_list) }}
<div style="text-align: center;">
<div class="ui pagination menu">
{% if items_past_page.has_previous() %}
<a class="item" href="?page={{ items_past_page.previous_page_number() }}">&lsaquo; Prev</a>
{% endif %}
<a class="active item">Page {{ items_past_page.number }} of {{ items_past_page.paginator.num_pages }}</a>
{% if items_past_page.has_next() %}
<a class="item" href="?page={{ items_past_page.next_page_number() }}">Next &rsaquo;</a>
{% endif %}
</div>
</div>
{% endif %}
{% endblock %}

View File

@ -1,4 +1,4 @@
{% extends 'sspromotions/email/base.html' %}
{% extends 'ssmain/email/base.html' %}
{#
Society Self-Service

View File

@ -33,6 +33,8 @@ import logging
import time
import urllib.parse
bulldt = timezone.localtime(timezone.now())
def send_aws_email(client, email, subject, content_html, content_txt):
def send_mail(**kwargs):
for i in range(0, 10):
@ -91,15 +93,19 @@ class Command(BaseCommand):
client = boto3.client('ses', aws_access_key_id=settings.AWS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET, region_name=settings.AWS_REGION)
title = '{} News: {}'.format(settings.ORG_NAME, timezone.now().strftime('%d %B %Y'))
title = '{} News: {}'.format(settings.ORG_NAME, bulldt.strftime('%d %B %Y'))
calbegin, calend, bulbegin, bulend = sspromotions.utils.bulletin_dates(timezone.now())
calbegin, calend, bulbegin, bulend = sspromotions.utils.bulletin_dates(bulldt)
events = list(sspromotions.utils.get_calendar_events(calbegin, calend))
for member in members:
if sspromotions.models.BulletinSubscription.is_member_subscribed(member):
template_args = sspromotions.utils.bulletin_args(member, sspromotions.models.Group.get_member_groups(member), events, bulbegin, bulend)
if (len(template_args['groups']) == 0 and len(template_args['more']) == 0) or member.expires < bulldt.date():
self.stdout.write('Skipping {} at {}'.format(member.id, member.email))
continue
content_html = premailer.Premailer(template_html.render(template_args), cssutils_logging_level=logging.ERROR, strip_important=False).transform()
content_txt = template_txt.render(template_args)

View File

@ -23,7 +23,7 @@ from django.db import models
from jsonfield import JSONField
class Group(models.Model):
name = models.CharField(max_length=100)
name = models.CharField(max_length=1000)
subscribable = models.BooleanField()
order = models.IntegerField(null=True, blank=True)
hidden = models.BooleanField()
@ -89,8 +89,8 @@ class BulletinItem(models.Model):
author = models.ForeignKey(User, on_delete=models.CASCADE)
group = models.ForeignKey(Group, on_delete=models.CASCADE)
also_limit = JSONField(default=[])
title = models.CharField(max_length=100)
link = models.CharField(max_length=100, null=True)
title = models.CharField(max_length=1000)
link = models.CharField(max_length=1000, null=True)
image = models.ImageField(upload_to='promo_uploads/%Y/%m/%d/', null=True)
content = models.TextField()
date = models.DateField()

View File

@ -22,7 +22,6 @@ urlpatterns = [
path('bulletin/', views.bulletin_list, name='bulletin_list'),
path('bulletin/new/', views.bulletin_new, name='bulletin_new'),
path('bulletin/edit/<int:id>', views.bulletin_edit, name='bulletin_edit'),
path('bulletin/delete/<int:id>', views.bulletin_delete, name='bulletin_delete'),
path('bulletin/preview/', views.bulletin_preview, name='bulletin_preview'),
path('', views.index, name='promotions'),
]

View File

@ -17,6 +17,7 @@
from django.contrib.auth.decorators import login_required
from django.conf import settings
from django.core.paginator import Paginator
from django.http import HttpResponse
from django.shortcuts import render, redirect
from django.urls import reverse
@ -38,7 +39,7 @@ def bulletin_list(request):
items_upcoming = []
items_future = []
dtbegin = timezone.now().date()
dtbegin = timezone.localtime(timezone.now()).date()
dtend = dtbegin + datetime.timedelta(days=7)
for item in models.BulletinItem.objects.all():
@ -52,8 +53,10 @@ def bulletin_list(request):
else:
items_past.append(item)
items_past_p = Paginator(items_past, 10)
return render(request, 'sspromotions/bulletin_list.html', {
'items_past': items_past,
'items_past_page': items_past_p.page(int(request.GET.get('page', 1))) if items_past_p.count > 0 else None,
'items_upcoming': items_upcoming,
'items_future': items_future
})
@ -67,12 +70,12 @@ def bulletin_preview(request):
dt = datetime.datetime.strptime(request.POST['date'], '%Y-%m-%d')
else:
groups = models.Group.objects.all()
dt = timezone.now() - datetime.timedelta(days=6)
dt = timezone.localtime(timezone.now()) - datetime.timedelta(days=6)
calbegin, calend, bulbegin, bulend = utils.bulletin_dates(dt)
return render(request, 'sspromotions/email/bulletin.html', utils.bulletin_args(None, groups, utils.get_calendar_events(calbegin, calend), bulbegin, bulend))
else:
date = timezone.now().date()
date = timezone.localtime(timezone.now()).date()
date += datetime.timedelta(days=(6 - date.weekday() + 7) % 7) # Next Sunday (6 = Sunday)
return render(request, 'sspromotions/bulletin_preview.html', {'date': date, 'groups': models.Group.objects.filter(hidden=False).all()})
@ -98,7 +101,7 @@ def bulletin_new(request):
else:
item = models.BulletinItem()
item.author = request.user
item.date = timezone.now().date()
item.date = timezone.localtime(timezone.now()).date()
item.date += datetime.timedelta(days=(6 - item.date.weekday() + 7) % 7) # Next Sunday (6 = Sunday)
return render(request, 'sspromotions/bulletin_edit.html', {
'item': item,
@ -109,9 +112,20 @@ def bulletin_new(request):
def bulletin_edit(request, id):
if request.method == 'POST':
item = models.BulletinItem.objects.get(id=id)
# Check access with old group
if not item.can_user_access(request.user):
return HttpResponse('Unauthorized', status=401)
if request.POST['submit'] == 'Delete':
item.delete()
return redirect(reverse('bulletin_list'))
# Check access with new group
item.group = models.Group.objects.get(id=int(request.POST['group']))
if not item.can_user_access(request.user):
return HttpResponse('Unauthorized', status=401)
item.title = request.POST['title']
item.date = request.POST['date']
item.content = request.POST['content']
@ -129,16 +143,8 @@ def bulletin_edit(request, id):
item = models.BulletinItem.objects.get(id=id)
if not item.can_user_access(request.user):
return HttpResponse('Unauthorized', status=401)
return render(request, 'sspromotions/bulletin_edit.html', {
'item': item,
'groups': models.Group.objects.filter(hidden=False).all()
})
@login_required
def bulletin_delete(request, id):
item = models.BulletinItem.objects.get(id=id)
if not item.can_user_access(request.user):
return HttpResponse('Unauthorized', status=401)
item.delete()
return redirect(reverse('bulletin_list'))

97
sstreasury/aba.py Normal file
View File

@ -0,0 +1,97 @@
# Society Self-Service
# Copyright © 2018–2020 Yingtong Li (RunasSudo)
#
# 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/>.
# https://www.brad-smith.info/blog/archives/405
# https://www.cemtexaba.com/aba-format/cemtex-aba-file-format-details
# https://ddkonline.blogspot.com/2009/01/aba-bank-payment-file-format-australian.html
class ABAException(Exception):
pass
def write_descriptive(f, reel_seq=1, bank_name='', user_name='', bank_code=0, description='', date=None):
if reel_seq < 0 or reel_seq > 99:
raise ABAException('Invalid Reel Sequence Number: {}'.format(reel_seq))
if len(bank_name) > 3:
raise ABAException('Invalid Financial Institution abbreviation: {}'.format(bank_name))
if len(user_name) > 26:
raise ABAException('Invalid User Preferred Specification: {}'.format(user_name))
if bank_code < 0 or bank_code > 999999:
raise ABAException('Invalid User Identification Number: {}'.format(bank_code))
if len(description) > 12:
raise ABAException('Invalid Description: {}'.format(description))
f.write(b'0') # Record Type 0
f.write(b' ' * 17) # Blank
f.write('{:02}'.format(reel_seq).encode('ascii')) # Reel Sequence Number
f.write('{: <3}'.format(bank_name).encode('ascii')) # Financial Institution abbreviation
f.write(b' ' * 7) # Blank
f.write('{: <26}'.format(user_name).encode('ascii')) # User Preferred Specification
f.write('{:06}'.format(bank_code).encode('ascii')) # User Identification Number
f.write('{: <12}'.format(description).encode('ascii')) # Description
f.write(date.strftime('%d%m%y').encode('ascii')) # Date
f.write(b' ' * 40) # Blank
f.write(b'\r\n')
def write_detail(f, dest_bsb='', dest_account='', indicator=' ', transaction_code=53, cents=0, dest_name='', reference='', src_bsb='', src_account='', src_name='', tax_withheld=0):
dest_bsb = dest_bsb.replace('-', '').replace(' ', '')
dest_account = dest_account.replace('-', '').replace(' ', '')
src_bsb = src_bsb.replace('-', '').replace(' ', '')
src_account = src_account.replace('-', '').replace(' ', '')
if len(dest_bsb) != 6:
raise ABAException('Invalid BSB: {}'.format(dest_bsb))
if len(dest_account) > 9:
raise ABAException('Invalid Account Number: {}'.format(dest_account))
if len(indicator) != 1:
raise ABAException('Invalid Indicator: {}'.format(indicator))
if transaction_code < 0 or transaction_code > 99:
raise ABAException('Invalid Transaction Code: {}'.format(indicator))
if len(dest_name) > 32:
raise ABAException('Invalid Title of Account: {}'.format(dest_name))
if len(reference) > 18:
raise ABAException('Invalid Lodgement Reference: {}'.format(reference))
if len(src_bsb) != 6:
raise ABAException('Invalid Trace BSB: {}'.format(src_bsb))
if len(src_account) > 9:
raise ABAException('Invalid Trace Account Number: {}'.format(src_account))
if len(src_name) > 16:
raise ABAException('Invalid Name of Remitter: {}'.format(src_name))
f.write(b'1') # Record Type 1
f.write('{}-{}'.format(dest_bsb[:3], dest_bsb[-3:]).encode('ascii')) # BSB
f.write('{: >9}'.format(dest_account).encode('ascii')) # Account Number
f.write(indicator.encode('ascii')) # Indicator
f.write('{:02}'.format(transaction_code).encode('ascii')) # Transaction Code
f.write('{:010}'.format(round(cents)).encode('ascii')) # Amount
f.write('{: <32}'.format(dest_name).encode('ascii')) # Title of Account
f.write('{: <18}'.format(reference).encode('ascii')) # Lodgement Reference
f.write('{}-{}'.format(src_bsb[:3], src_bsb[-3:]).encode('ascii')) # Trace BSB
f.write('{: >9}'.format(src_account).encode('ascii')) # Trace Account Number
f.write('{: <16}'.format(src_name).encode('ascii')) # Name of Remitter
f.write('{:08}'.format(round(tax_withheld)).encode('ascii')) # Amount of Withholding Tax
f.write(b'\r\n')
def write_total(f, credit_cents=0, num_detail_records=0):
f.write(b'7') # Record Type 7
f.write(b'999-999') # BSB Format Filler
f.write(b' ' * 12) # Blank
f.write('{:010}'.format(round(credit_cents)).encode('ascii')) # File (User) Net Total Amount
f.write('{:010}'.format(round(credit_cents)).encode('ascii')) # File (User) Credit Total Amount
f.write(b'0' * 10) # File (User) Debit Total Amount
f.write(b' ' * 24) # Blank
f.write('{:06}'.format(num_detail_records).encode('ascii')) # File (user) count of Records Type 1
f.write(b' ' * 40) # Blank
f.write(b'\r\n')

View File

@ -2,7 +2,8 @@
{#
Society Self-Service
Copyright © 2018 Yingtong Li (RunasSudo)
Copyright © 2018–2023 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -19,9 +20,9 @@
#}
{% block content %}
<div class="ui grid">
<div class="ui stackable grid">
{# side menu #}
<div class="four wide column">
<div class="four wide column" id="sidebar">
<div class="ui vertical fluid menu">
<div class="item">
Budgets
@ -33,15 +34,20 @@
<div class="item">
Reimbursements
<div class="menu">
<a class="item">Your reimbursement claims</a>
<a class="item">Create new claim</a>
<a class="{% if request.resolver_match.url_name == 'claim_list' %}active {% endif %}item" href="{{ url('claim_list') }}">Your reimbursement claims</a>
<a class="{% if request.resolver_match.url_name == 'claim_new' %}active {% endif %}item" href="{{ url('claim_new') }}">Create new claim</a>
{% if request.user.groups.filter(name='Treasury').exists() %}
<a class="{% if request.resolver_match.url_name == 'claim_processing' %}active {% endif %}item" href="{{ url('claim_processing') }}">Claims processing</a>
{% endif %}
</div>
</div>
</div>
</div>
<div class="twelve wide column">
{% block maincontent %}{% endblock %}
</div>
{% block aftersidebar %}
<div class="twelve wide column">
{% block maincontent %}{% endblock %}
</div>
{% endblock %}
</div>
{% endblock %}

View File

@ -2,7 +2,8 @@
{#
Society Self-Service
Copyright © 2018 Yingtong Li (RunasSudo)
Copyright © 2018-2023 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -24,16 +25,28 @@
<h1>{% if request.resolver_match.url_name == 'budget_new' %}New{% else %}Edit{% endif %} budget</h1>
<form class="ui form" method="POST">
{% if errors %}
<div class="ui visible error message">
<div class="header">Invalid input</div>
<p>Unable to save the budget. Please correct the issues below and try again:</p>
<ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
<div class="ui disabled inline grid field">
<label class="three wide column">ID</label>
<input class="eleven wide column" type="text" name="id" value="{{ revision.budget.id if revision.budget.id != None else '' }}">
<input class="eleven wide column" type="text" name="id" value="{{ 'BU-{}'.format(revision.budget.id) if revision.budget.id != None else '' }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Name</label>
<label class="three wide column">Title</label>
<input class="eleven wide column" type="text" name="name" value="{{ revision.name }}">
</div>
<div class="ui inline grid field">
<label class="three wide column">Due date</label>
<div class="ui required inline grid field">
<label class="three wide column">Due date <span data-tooltip="The date you require budget approval by"><i class="grey question circle icon"></i></span></label>
<div class="eleven wide column">
<div class="ui calendar" id="cal_date">
<div class="ui input left icon grid">
@ -43,10 +56,49 @@
</div>
</div>
</div>
<div class="ui required inline grid field">
<label class="three wide column">Contributors</label>
<div class="ui inline grid field">
<label class="three wide column">Event details <span data-tooltip="Leave blank if not applicable"><i class="grey question circle icon"></i></span></label>
<div class="two wide column">
Date/time
</div>
<div class="nine wide column">
<span class="ui calendar" id="cal_event_dt">
<span class="ui input left icon grid">
<i class="calendar icon" style="z-index: 999;"></i>
<input class="nine wide column" type="text" name="event_dt" value="{{ localtime(revision.event_dt) if revision.event_dt else '' }}">
</span>
</span>
</div>
</div>
<div class="ui inline grid field">
<div class="three wide column"></div>
<div class="four wide column">Estimated no. of attendees</div>
<input class="seven wide column" type="text" name="event_attendees" value="{{ revision.event_attendees or '' }}">
</div>
<div class="ui inline grid field">
<label class="three wide column">Contributors <span data-tooltip="To share this budget with other contributors, enter their email addresses, one per line"><i class="grey question circle icon"></i></span></label>
<textarea class="eleven wide column" rows="2" name="contributors" style="font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;">{{ contributors }}</textarea>
</div>
<div class="ui required inline grid field">
<label class="three wide column">Cost centre</label>
<div class="eleven wide column" style="padding: 0;">
<select class="ui dropdown" name="cost_centre">
{% for cost_centre in settings.BUDGET_COST_CENTRES %}
<option value="{{ cost_centre }}"{% if cost_centre == revision.cost_centre %} selected{% endif %}>{{ cost_centre }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="ui required inline grid field">
<label class="three wide column">Responsible committee</label>
<div class="eleven wide column" style="padding: 0;">
<select class="ui dropdown" name="approver">
{% for approver in settings.AVAILABLE_APPROVERS %}
<option value="{{ approver[0] }}"{% if approver[0] == revision.approver %} selected{% endif %}>{{ approver[1][0] }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="ui divider"></div>
<div class="ui inline grid field">
<label class="three wide column">Comments</label>
@ -55,13 +107,14 @@
<div class="ui divider"></div>
<div class="ui inline grid field">
<label class="three wide column">Revenue</label>
<div id="revenue_grid"></div>
<input type="hidden" name="revenue" id="revenue_input">
<div class="eleven wide column"></div>
</div>
<div id="revenue_grid"></div>
<input type="hidden" name="revenue" id="revenue_input">
<div class="ui accordion">
<div class="{% if revision.revenue_comments %}active {% endif %}title">
<i class="dropdown icon"></i>
Revenue comments
Revenue comments (click to show/hide)
</div>
<div class="content">
<div class="ui inline grid field">
@ -73,16 +126,17 @@
<div class="ui divider"></div>
<div class="ui inline grid field">
<label class="three wide column">Expenses</label>
<div id="expense_grid"></div>
<input type="hidden" name="expense" id="expense_input">
<div class="eleven wide column"><input type="checkbox" name="expense_no_emergency_fund" id="expense_no_emergency_fund"{% if revision.expense_no_emergency_fund %} checked{% endif %}> <label for="expense_no_emergency_fund">No emergency fund required (please add a comment explaining why)</label></div>
</div>
<div class="ui accordion">
<div class="{% if revision.expense_comments %}active {% endif %}title">
<div id="expense_grid"></div>
<input type="hidden" name="expense" id="expense_input">
<div class="ui accordion" id="expense_comments_accordion">
<div class="{% if revision.expense_comments or revision.expense_no_emergency_fund %}active {% endif %}title">
<i class="dropdown icon"></i>
Expense comments
Expense comments (click to show/hide)
</div>
<div class="{% if revision.expense_comments %}active {% endif %}content">
<div class="ui inline grid field">
<div class="{% if revision.expense_comments or revision.expense_no_emergency_fund %}active {% endif %}content">
<div class="ui {% if revision.expense_no_emergency_fund %}required {% endif %}inline grid field">
<label class="three wide column">Comments</label>
<textarea class="eleven wide column" rows="2" name="expense_comments">{{ revision.expense_comments }}</textarea>
</div>
@ -91,8 +145,11 @@
<div class="ui divider"></div>
<div class="ui error message"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Save">
<button class="ui primary button" type="submit" name='submit' value="Save">Save as draft</button>
<input class="ui button" type="submit" name='submit' value="Save and continue editing">
{% if request.resolver_match.url_name == 'budget_edit' %}
<input class="ui right floated red button" type="submit" name='submit' value="Delete" onclick="return confirm('Are you sure you want to delete this budget? This action is IRREVERSIBLE.');">
{% endif %}
</form>
{% endblock %}
@ -103,6 +160,14 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.css" integrity="sha256-a/jNbtm7jpeKiXCShJ8YC+eNL9Abh7CBiYXHgaofUVs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid-theme.min.css" integrity="sha256-0rD7ZUV4NLK6VtGhEim14ZUZGC45Kcikjdcr4N03ddA=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dragula@3.7.2/dist/dragula.min.css" integrity="sha256-iVhQxXOykHeL03K08zkxBGxDCLCuzRGGiTYf2FL6mLY=" crossorigin="anonymous">
<style type="text/css">
/* Make dropdowns match form style */
.ui.form .column .ui.dropdown {
padding: 1rem;
width: 100%;
}
</style>
{% endblock %}
{% block script %}
@ -112,13 +177,14 @@
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dragula@3.7.2/dist/dragula.min.js" integrity="sha256-ug4bHfqHFAj2B5MESRxbLd3R3wdVMQzug2KHZqFEmFI=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/budget.js') }}"></script>
<script>
function leftpad(n) {
if (n < 10)
return '0' + n;
return '' + n;
}
$('#cal_date').calendar({
type: 'date',
formatter: {
@ -127,23 +193,40 @@
}
}
});
$('#cal_event_dt').calendar({
type: 'datetime',
formatter: {
date: function(date, settings) {
return date.getFullYear() + '-' + leftpad(date.getMonth() + 1) + '-' + leftpad(date.getDate());
},
time: function(date, settings, forCalendar) {
return leftpad(date.getHours()) + ':' + leftpad(date.getMinutes());
}
}
});
$('.ui.dropdown').dropdown();
$('.ui.accordion').accordion();
$('#expense_no_emergency_fund').change(function() {
if ($('#expense_no_emergency_fund').prop('checked')) {
$('#expense_comments_accordion').accordion('open', 0);
$('#expense_comments_accordion .field').addClass('required');
} else {
$('#expense_comments_accordion .field').removeClass('required');
}
recalcExpTotal({'grid': $('#expense_grid').data('JSGrid')});
});
$('.ui.form').form({
on: 'blur',
keyboardShortcuts: false,
fields: {
name: 'empty',
contributors: 'empty'
},
onSuccess: function(event, fields) {
var revenue_data = [];
$('#revenue_grid .jsgrid-grid-body tr:not(.totalrow)').each(function(i, el) {
$('#revenue_grid .jsgrid-grid-body tr:not(.totalrow):not(.jsgrid-nodata-row)').each(function(i, el) {
var row = $(el).data('JSGridItem');
revenue_data.push({
'Description': row['Description'],
'Unit cost': row['Unit cost'],
'Unit price': row['Unit price'],
'Units': row['Units'],
'IWT': row['IWT'],
});
@ -151,11 +234,11 @@
$('#revenue_input').val(JSON.stringify(revenue_data));
var expense_data = [];
$('#expense_grid .jsgrid-grid-body tr:not(.totalrow)').each(function(i, el) {
$('#expense_grid .jsgrid-grid-body tr:not(.totalrow):not(.jsgrid-nodata-row)').each(function(i, el) {
var row = $(el).data('JSGridItem');
expense_data.push({
'Description': row['Description'],
'Unit cost': row['Unit cost'],
'Unit price': row['Unit price'],
'Units': row['Units'],
});
});
@ -164,7 +247,7 @@
});
// Interferes with jsGrid
$('.ui.form').on('keyup keypress', function(e) {
$('.ui.form').on('keyup keypress', ':input:not(textarea)', function(e) {
var keyCode = e.keyCode || e.which;
if (keyCode === 13) {
e.preventDefault();
@ -172,110 +255,14 @@
}
});
function recalcRevTotal(args) {
//console.log(args);
var total = 0;
var totalIWT = 0;
for (var row of args.grid.data) {
total += row['Unit cost'] * row['Units'];
if (row['Unit cost'] > 0 && row['IWT']) {
totalIWT += (row['Unit cost'] - (row['Unit cost'] - 0.8) / 1.019) * row['Units'];
}
}
$(args.grid._body).find('.totalrow').remove();
if (totalIWT > 0) {
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Less IWT fees:</td>').prop('colspan', args.grid.fields.length - 2));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('($' + totalIWT.toFixed(2) + ')'));
totalrow.append($('<td class="jsgrid-cell"></td>'));
$(args.grid._body).find('tr:last').after(totalrow);
}
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - 2));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (total - totalIWT).toFixed(2)));
totalrow.append($('<td class="jsgrid-cell"></td>'));
$(args.grid._body).find('tr:last').after(totalrow);
}
function recalcExpTotal(args) {
var total = 0;
for (var row of args.grid.data) {
total += row['Unit cost'] * row['Units'];
}
$(args.grid._body).find('.totalrow').remove();
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Plus emergency fund:</td>').prop('colspan', args.grid.fields.length - 2));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (total * 0.05).toFixed(2)));
totalrow.append($('<td class="jsgrid-cell"></td>'));
$(args.grid._body).find('tr:last').after(totalrow);
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - 2));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (total * 1.05).toFixed(2)));
totalrow.append($('<td class="jsgrid-cell"></td>'));
$(args.grid._body).find('tr:last').after(totalrow);
}
// Allow floats
function FloatNumberField(config) {
jsGrid.NumberField.call(this, config);
}
FloatNumberField.prototype = new jsGrid.NumberField({
filterValue: function() {
return parseFloat(this.filterControl.val());
},
insertValue: function() {
return parseFloat(this.insertControl.val());
},
editValue: function() {
return parseFloat(this.editControl.val());
}
});
jsGrid.fields.float = FloatNumberField;
const ticketingFeeName = JSON.parse({{ import('json').dumps(import('json').dumps(settings.TICKETING_FEE_NAME))|safe }});
const ticketingFeeProportion = {{ settings.TICKETING_FEE_PROPORTION }};
const ticketingFeeFixed = {{ settings.TICKETING_FEE_FIXED }};
const editing = true;
var revenue_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.revenue))|safe }});
$('#revenue_grid').jsGrid({
width: '100%',
height: '20em',
inserting: true,
editing: true,
noDataContent: 'No entries',
data: revenue_data,
fields: [
{ name: 'Description', type: 'text', width: '55%', validate: 'required' },
{ name: 'Unit cost', type: 'float', width: '10%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '10%', validate: 'required' },
{ name: 'IWT', type: 'checkbox', width: '5%' },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit cost'] * item['Units']).toFixed(2); } },
{ type: 'control', width: '10%' },
],
onItemUpdated: recalcRevTotal,
onRefreshed: recalcRevTotal,
});
var expense_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.expense))|safe }});
$('#expense_grid').jsGrid({
width: '100%',
height: '20em',
inserting: true,
editing: true,
noDataContent: 'No entries',
data: expense_data,
fields: [
{ name: 'Description', type: 'text', width: '55%', validate: 'required' },
{ name: 'Unit cost', type: 'float', width: '10%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '10%', validate: 'required' },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit cost'] * item['Units']).toFixed(2); } },
{ type: 'control', width: '10%' },
],
onItemUpdated: recalcExpTotal,
onRefreshed: recalcExpTotal,
});
makeGrid();
dragula([document.querySelector('#revenue_grid tbody')], {
accepts: function (el, target, source, sibling) {
@ -293,5 +280,7 @@
return el.classList.contains('totalrow');
}
});
//$('.jsgrid-insert-mode-button').click();
</script>
{% endblock %}

View File

@ -2,7 +2,8 @@
{#
Society Self-Service
Copyright © 2018 Yingtong Li (RunasSudo)
Copyright © 2018–2023 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -24,15 +25,19 @@
<table class="ui selectable celled table">
<thead>
<tr>
<th class="twelve wide">Name</th>
<th class="eleven wide">Name</th>
<th class="four wide">Status</th>
<th class="one wide">View</th>
</tr>
</thead>
</thea d>
<tbody>
{% for revision in budgets %}
<tr>
<td class="selectable"><a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">{{ revision.name }}</a></td>
<td class="selectable"><a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">{{ import('sstreasury.models').BudgetState(revision.state).description }}</a></td>
<td class="selectable"><a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">{{ revision.get_state_display() }}</a></td>
<td>
<a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
@ -42,8 +47,39 @@
{% block maincontent %}
<h1>Your budgets</h1>
<form class="ui form" method="GET">
<div class="fields">
<div class="seven wide field">
<label>State</label>
<select class="ui dropdown" name="state">
<option value="all"{% if request.GET.get('state', 'all') == 'all' %} selected{% endif %}>All states</option>
{% for state in import('sstreasury.models').BudgetState %}
<option value="{{ state._value_ }}"{% if request.GET.get('state', 'all') == state._value_|string %} selected{% endif %}>{{ state.description }}</option>
{% endfor %}
</select>
</div>
<div class="four wide field">
<label>Cost centre</label>
<select class="ui dropdown" name="cost_centre">
<option value="all"{% if request.GET.get('cost_centre', 'all') == 'all' %} selected{% endif %}>All cost centres</option>
{% for cost_centre in settings.BUDGET_COST_CENTRES %}
<option value="{{ cost_centre }}"{% if request.GET.get('cost_centre', 'all') == cost_centre %} selected{% endif %}>{{ cost_centre }}</option>
{% endfor %}
</select>
</div>
<div class="two wide field">
<label>Year</label>
<input name="year" value="{{ request.GET.get('year', '') }}">
</div>
<div class="three wide field">
<label>&nbsp;</label>
<button type="submit" class="ui primary labeled icon button" style="width:100%"><i class="filter icon"></i>Filter</button>
</div>
</div>
</form>
{% if not budgets_action and not budgets_open and not budgets_closed %}
<p>You have no budgets to view. To create a budget, click <a href="{{ url('budget_new') }}">Create new budget</a>.</p>
<p>There are no budgets matching the selected criteria. To create a budget, click <a href="{{ url('budget_new') }}">Create new budget</a>.</p>
{% endif %}
{% if budgets_action %}
@ -55,20 +91,78 @@
{% if budgets_open %}
<h2>Open budgets</h2>
{% for budget in budgets_open %}
{{ budget.name }}
{% endfor %}
{{ listbudgets(budgets_open) }}
{% endif %}
{% if budgets_closed %}
<h2>Closed budgets</h2>
{% for budget in budgets_closed %}
{{ budget.name }}
{% endfor %}
{{ listbudgets(budgets_closed) }}
{% endif %}
<div style="text-align:center;padding-top:1em">
<div class="ui pagination menu">
{% if page.has_previous() %}
<a class="item" href="?page={{ page.previous_page_number() }}&state={{ request.GET.get('state', 'all') }}&cost_centre={{ request.GET.get('cost_centre', 'all') }}&year={{ request.GET.get('year', '') }}">&lsaquo; Prev</a>
{% endif %}
<a class="active item">Page {{ page.number }} of {{ page.paginator.num_pages }}</a>
{% if page.has_next() %}
<a class="item" href="?page={{ page.next_page_number() }}&state={{ request.GET.get('state', 'all') }}&cost_centre={{ request.GET.get('cost_centre', 'all') }}&year={{ request.GET.get('year', '') }}">Next &rsaquo;</a>
{% endif %}
</div>
</div>
{% if yearly_totals %}
<h2>Yearly totals</h2>
<canvas id="chartYearlyTotals"></canvas>
{% endif %}
{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block script %}
{{ super() }}
<script>
$('.ui.dropdown').dropdown();
</script>
{% if yearly_totals %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.3.0/dist/chart.umd.js" integrity="sha256-5dsP1lVzcWPn5aOwu+zs+G+TqAu9oT8NUNM0C4n3H+4=" crossorigin="anonymous"></script>
<script>
const yearlyTotalsData = JSON.parse({{ import('json').dumps(import('json').dumps(yearly_totals))|safe }});
new Chart(document.getElementById('chartYearlyTotals'), {
type: 'bar',
data: {
labels: yearlyTotalsData.map(x => x[0]),
datasets: [{
label: {{ import('json').dumps(import('json').dumps(request.GET.get('cost_centre', 'all')))|safe }},
data: yearlyTotalsData.map(x => x[1]),
backgroundColor: yearlyTotalsData.map(x => x[1] >= 0 ? '#36a2eb' : '#ff6384')
}]
},
options: {
indexAxis: 'y',
scales: {
x: {
beginAtZero: true
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: i => '$' + i.parsed.x.toFixed(2)
}
}
}
},
});
</script>
{% endif %}
{% endblock %}

View File

@ -0,0 +1,171 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2023 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}{{ revision.name }}{% endblock %}
{% block content %}
<h1>{{ revision.name }}</h1>
<span class="ui header">Status: {{ revision.get_state_display() }}</span>
{% if not is_latest %}
<div class="ui warning message">
<p>You are printing an older version of this budget.</p>
</div>
{% endif %}
<table class="ui mydefinition table">
<tbody>
<tr>
<td class="two wide">ID</td>
<td class="fourteen wide">BU-{{ revision.budget.id }}</td>
</tr>
<tr>
<td>Title</td>
<td>{{ revision.name }}</td>
</tr>
<tr>
<td>Due date</td>
<td>{{ revision.date or '' }}</td>
</tr>
<tr>
<td>Event details</td>
<td>
{% if revision.event_dt %}{{ localtime(revision.event_dt) }}.{% endif %}
{% if revision.event_attendees %}{{ revision.event_attendees }} attendees.{% endif %}
{% if not revision.event_dt and not revision.event_attendees %}N/A{% endif %}
</td>
</tr>
<tr>
<td>Contributors</td>
<td>
<div class="ui list">
{% for contributor in revision.contributors.all() %}
<div class="item">
<i class="user circle icon"></i>
<div class="content"><a href="mailto:{{ contributor.email }}">
{% if contributor.first_name %}
{{ contributor.first_name }} {{ contributor.last_name }}
{% else %}
{{ contributor.email }}
{% endif %}
</a></div>
</div>
{% endfor %}
</div>
</td>
</tr>
<tr>
<td>Comments</td>
<td>{{ revision.comments|markdown }}</td>
</tr>
<tr>
<td>Revenue</td>
<td>
<div id="revenue_grid"></div>
{% if revision.revenue_comments %}
<div class="ui accordion">
<div class="active title">
<i class="dropdown icon"></i>
Revenue comments
</div>
<div class="active content">
{{ revision.revenue_comments|markdown }}
</div>
</div>
{% endif %}
</td>
</tr>
<tr>
<td>Expenses</td>
<td>
{% if revision.expense_no_emergency_fund %}
<p><input type="checkbox" id="expense_no_emergency_fund" disabled checked> No emergency fund required (please add a comment explaining why)</p>
{% endif %}
<div id="expense_grid"></div>
{% if revision.expense_comments %}
<div class="ui accordion">
<div class="active title">
<i class="dropdown icon"></i>
Expense comments
</div>
<div class="active content">
{{ revision.expense_comments|markdown }}
</div>
</div>
{% endif %}
</td>
</tr>
<tr>
<td>Total Profit (Loss)</td>
<td style="text-align: right; font-weight: bold; padding-right: 2.3em;">{{ '${:.2f}'.format(revision.get_revenue_total() - revision.get_expense_total()) if revision.get_revenue_total() >= revision.get_expense_total() else '(${:.2f})'.format(revision.get_expense_total() - revision.get_revenue_total()) }}</td>
</tr>
</tbody>
</table>
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.css" integrity="sha256-a/jNbtm7jpeKiXCShJ8YC+eNL9Abh7CBiYXHgaofUVs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid-theme.min.css" integrity="sha256-0rD7ZUV4NLK6VtGhEim14ZUZGC45Kcikjdcr4N03ddA=" crossorigin="anonymous">
<style>
/* Fix the CSS */
.ui.mydefinition.table > tbody > tr > td:first-child:not(.ignored) {
background: rgba(0,0,0,.03);
font-weight: 700;
color: rgba(0,0,0,.95);
}
.jsgrid-align-right, .jsgrid-align-right input, .jsgrid-align-right select, .jsgrid-align-right textarea {
text-align: right !important;
}
.jsgrid-cell {
padding: .5em !important;
}
.jsgrid-header-row .jsgrid-header-cell {
text-align: center !important;
}
</style>
{% endblock %}
{% block script %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/budget.js') }}"></script>
<script>
const ticketingFeeName = JSON.parse({{ import('json').dumps(import('json').dumps(settings.TICKETING_FEE_NAME))|safe }});
const ticketingFeeProportion = {{ settings.TICKETING_FEE_PROPORTION }};
const ticketingFeeFixed = {{ settings.TICKETING_FEE_FIXED }};
const editing = false;
var revenue_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.revenue))|safe }});
var expense_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.expense))|safe }});
makeGrid();
print();
</script>
{% endblock %}

View File

@ -2,7 +2,8 @@
{#
Society Self-Service
Copyright © 2018 Yingtong Li (RunasSudo)
Copyright © 2018–2023 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
@ -20,142 +21,419 @@
{% block title %}{{ revision.name }}{% endblock %}
{% block maincontent %}
<h1>{{ revision.name }}</h1>
{% block aftersidebar %}
<div class="eight wide column">
<h1>{{ revision.name }}</h1>
{% if is_latest %}
<div>
<span class="ui header">Status: {{ import('sstreasury.models').BudgetState(revision.state).description }}</span>
<a class="ui mini labeled right floated icon button" href="{{ url('budget_edit', kwargs={'id': revision.budget.id}) }}"><i class="edit icon"></i> Edit</a>
</div>
{% else %}
<div class="ui warning message">
<p>You are viewing an older version of this budget. To make any changes, <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">click here</a> to return to the current version.</p>
</div>
{% endif %}
<table class="ui mydefinition table">
<tbody>
<tr>
<td class="two wide">ID</td>
<td class="fourteen wide">{{ revision.budget.id }}</td>
</tr>
<tr>
<td>Name</td>
<td>{{ revision.name }}</td>
</tr>
<tr>
<td>Due date</td>
<td>{{ revision.date or '' }}</td>
</tr>
<tr>
<td>Contributors</td>
<td>
<div class="ui list">
{% for contributor in revision.contributors.all() %}
<div class="item">
<i class="user circle icon"></i>
<div class="content"><a href="mailto:{{ contributor.email }}">
{% if contributor.first_name %}
{{ contributor.first_name }} {{ contributor.last_name }}
{% else %}
{{ contributor.email }}
{% endif %}
</a></div>
</div>
{% endfor %}
</div>
</td>
</tr>
<tr>
<td>Comments</td>
<td>{{ revision.comments }}</td>
</tr>
<tr>
<td>Revenue</td>
<td>
<div id="revenue_grid"></div>
{% if revision.revenue_comments %}
<div class="ui accordion">
<div class="active title">
<i class="dropdown icon"></i>
Revenue comments
</div>
<div class="active content">
{{ revision.revenue_comments }}
</div>
</div>
{% endif %}
</td>
</tr>
<tr>
<td>Expenses</td>
<td>
<div id="expense_grid"></div>
{% if revision.expense_comments %}
<div class="ui accordion">
<div class="active title">
<i class="dropdown icon"></i>
Expense comments
</div>
<div class="active content">
{{ revision.expense_comments }}
</div>
</div>
{% endif %}
</td>
</tr>
</tbody>
</table>
{% if is_latest %}
<form class="ui form" action="{{ url('budget_action', kwargs={'id': revision.budget.id}) }}" method="POST">
<div class="required field">
<textarea rows="4" name="comment"></textarea>
</div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name="action" value="Comment">
</form>
<span class="ui header">Status: {{ revision.get_state_display() }}</span>
<div class="ui feed">
{% for item in history %}
{% if item.__class__.__name__ == 'BudgetComment' %}
<div class="event">
<div class="label">
<i class="comment alternate outline icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name}}</a> commented
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
<div class="extra text">
{{ item.content }}
</div>
</div>
</div>
{% elif item.__class__.__name__ == 'BudgetRevision' %}
<div class="event">
<div class="label">
<i class="edit icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name}}</a> edited the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
</div>
{% if is_latest %}
{% if revision.can_submit(request.user) %}
<button class="ui mini labeled primary icon button" data-action="Submit" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="paper plane icon"></i> Submit</button>
{% endif %}
{% if revision.can_endorse(request.user) %}
<button class="ui mini labeled positive icon button" data-action="Endorse" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="check icon"></i> Endorse</button>
<button class="ui mini labeled basic negative icon button" data-action="Return" onclick="return uiConfirm(this);"><i class="undo icon"></i> Return for re-drafting</button>
{% endif %}
{% if revision.can_approve(request.user) %}
<button class="ui mini labeled positive icon button" data-action="Approve" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="check icon"></i> Approve</button>
<button class="ui mini labeled basic negative icon button" data-action="CmteReturn" onclick="return uiConfirm(this);"><i class="undo icon"></i> Return for re-drafting</button>
{% endif %}
{% if revision.can_withdraw(request.user) %}
<button class="ui mini labeled basic negative icon button" data-action="Withdraw" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="undo icon"></i> Withdraw</button>
{% endif %}
{% if revision.can_cancel(request.user) %}
<button class="ui mini labeled basic negative icon button" data-action="Cancel" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="times circle outline icon"></i> Cancel</button>
{% endif %}
{% if revision.can_edit(request.user) %}
<a class="ui mini labeled right floated icon button" href="{{ url('budget_edit', kwargs={'id': revision.budget.id}) }}"><i class="edit icon"></i> Edit</a>
{% endif %}
<a class="ui mini labeled right floated icon button" href="{{ url('budget_print', kwargs={'id': revision.budget.id}) }}" target="_blank"><i class="print icon"></i> Print</a>
{% if not revision.can_edit(request.user) and revision.can_withdraw(request.user) %}
<div class="ui message">
<p>This budget has been submitted and is now awaiting approval. If you wish to edit this budget, you must first withdraw it. This will revert the budget to a draft.</p>
</div>
{% endif %}
{% endfor %}
<input type="hidden" name="action" value="">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
{% else %}
<a class="ui mini labeled right floated icon button" href="{{ url('budget_print', kwargs={'id': revision.budget.id}) }}?revision={{ revision.id }}" target="_blank"><i class="print icon"></i> Print</a>
<div class="ui visible warning message">
<p>You are viewing an older version of this budget. To make any changes, <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}">click here</a> to return to the current version.</p>
</div>
{% endif %}
</form>
<table class="ui mydefinition table">
<tbody>
<tr>
<td class="two wide">ID</td>
<td class="fourteen wide">BU-{{ revision.budget.id }}</td>
</tr>
<tr>
<td>Title</td>
<td>{{ revision.name }}</td>
</tr>
<tr>
<td>Due date</td>
<td>{{ revision.date or '' }}</td>
</tr>
<tr>
<td>Event details</td>
<td>
{% if revision.event_dt %}{{ localtime(revision.event_dt) }}.{% endif %}
{% if revision.event_attendees %}{{ revision.event_attendees }} attendees.{% endif %}
{% if not revision.event_dt and not revision.event_attendees %}N/A{% endif %}
</td>
</tr>
<tr>
<td>Contributors</td>
<td>
<div class="ui list">
{% for contributor in revision.contributors.all() %}
<div class="item">
<i class="user circle icon"></i>
<div class="content"><a href="mailto:{{ contributor.email }}">
{% if contributor.first_name %}
{{ contributor.first_name }} {{ contributor.last_name }}
{% else %}
{{ contributor.email }}
{% endif %}
</a></div>
</div>
{% endfor %}
</div>
</td>
</tr>
<tr>
<td>Cost centre</td>
<td>{{ revision.cost_centre }}</td>
</tr>
<tr>
<td>Responsible committee</td>
<td>{{ dict(settings.AVAILABLE_APPROVERS)[revision.approver][0] }}</td>
</tr>
<tr>
<td>Comments</td>
<td>{{ revision.comments|markdown }}</td>
</tr>
<tr>
<td>Revenue</td>
<td>
<div id="revenue_grid"></div>
{% if revision.revenue_comments %}
<div class="ui accordion">
<div class="active title">
<i class="dropdown icon"></i>
Revenue comments
</div>
<div class="active content">
{{ revision.revenue_comments|markdown }}
</div>
</div>
{% endif %}
</td>
</tr>
<tr>
<td>Expenses</td>
<td>
{% if revision.expense_no_emergency_fund %}
<p><input type="checkbox" id="expense_no_emergency_fund" disabled checked> No emergency fund required (please add a comment explaining why)</p>
{% endif %}
<div id="expense_grid"></div>
{% if revision.expense_comments %}
<div class="ui accordion">
<div class="active title">
<i class="dropdown icon"></i>
Expense comments
</div>
<div class="active content">
{{ revision.expense_comments|markdown }}
</div>
</div>
{% endif %}
</td>
</tr>
<tr>
<td>Total Profit (Loss)</td>
<td style="text-align: right; font-weight: bold; padding-right: 2.3em;">{{ '${:.2f}'.format(revision.get_revenue_total() - revision.get_expense_total()) if revision.get_revenue_total() >= revision.get_expense_total() else '(${:.2f})'.format(revision.get_expense_total() - revision.get_revenue_total()) }}</td>
</tr>
</tbody>
</table>
{% if is_latest %}
<form class="ui form" action="{{ url('budget_action', kwargs={'id': revision.budget.id}) }}" method="POST">
<div class="required field">
<textarea rows="4" name="comment"></textarea>
</div>
<input type="hidden" name="action">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<button class="ui primary button" data-action="Comment" onclick="return uiSubmit(this);">Comment</button>
{% if revision.state == import('sstreasury.models').BudgetState.AWAIT_REVIEW.value and request.user.groups.filter(name='Treasury').exists() %}
<button class="ui right floated labeled basic negative icon button" data-action="Comment,Return" onclick="return uiConfirm(this);"><i class="undo icon"></i> Comment and return for re-drafting</button>
<button class="ui right floated labeled positive icon button" data-action="Comment,Endorse" onclick="return uiConfirm(this);"><i class="check icon"></i> Comment and endorse</button>
{% elif revision.state != import('sstreasury.models').BudgetState.APPROVED.value and request.user.groups.filter(name='Secretary').exists() %}
<button class="ui right floated labeled basic negative icon button" data-action="Comment,CmteReturn" onclick="return uiConfirm(this);"><i class="undo icon"></i> Comment and return for re-drafting</button>
<button class="ui right floated labeled positive icon button" data-action="Comment,Approve" onclick="return uiConfirm(this);"><i class="check icon"></i> Comment and approve</button>
{% endif %}
</form>
<div class="ui feed">
{% for item in history %}
{% if item.__class__.__name__ == 'BudgetComment' %}
<div class="event">
<div class="label">
<i class="comment alternate outline icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> commented
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
<div class="extra text">
{{ item.content|markdown }}
</div>
</div>
</div>
{% elif item.__class__.__name__ == 'BudgetRevision' %}
<div class="event">
<div class="label">
<i class="edit icon"></i>
</div>
<div class="content">
<div class="summary">
{% if item.action == import('sstreasury.models').BudgetAction.CREATE.value %}
<i class="user circle icon"></i> <a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> created the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% elif item.action == import('sstreasury.models').BudgetAction.EDIT.value %}
<i class="user circle icon"></i> <a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> edited the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% elif item.action == import('sstreasury.models').BudgetAction.UPDATE_STATE.value %}
<i class="user circle icon"></i> <a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> changed the state to: {{ item.get_state_display() }} <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% elif item.action == import('sstreasury.models').BudgetAction.AUTO_APPROVE.value %}
System changed the state to: {{ item.get_state_display() }} <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% else %}
<i class="user circle icon"></i> <a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> modified the budget <a href="{{ url('budget_view', kwargs={'id': revision.budget.id}) }}?revision={{ item.id }}">(view)</a>
{% endif %}
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
</div>
</div>
{% elif item.__class__.__name__ == 'BudgetVote' %}
<div class="event">
<div class="label">
<i class="gavel icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.voter.email }}">{{ item.voter.first_name }} {{ item.voter.last_name }}</a>
{% if item.vote_type == import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value %}
voted in favour of the budget
{% elif item.vote_type == import('sstreasury.models').BudgetVoteType.AGAINST.value %}
voted against the budget
{% elif item.vote_type == import('sstreasury.models').BudgetVoteType.ABSTAIN.value %}
abstained from voting
{% endif %}
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% if revision.state == import('sstreasury.models').BudgetState.ENDORSED.value %}
<h2>Committee voting</h2>
<form action="{{ url('budget_action', kwargs={'id': revision.budget.id}) }}" method="POST">
<p>
{{ dict(settings.AVAILABLE_APPROVERS)[revision.approver][1] }} votes in favour are required for approval.
{% if is_latest and request.user.groups.filter(name='Executive').exists() %}
<button style="margin-left: 1em;" class="ui small primary labeled icon button" type="submit" name="action" value="SendVotingReminders"><i class="envelope icon"></i>Send urgent reminder emails</button>
{% endif %}
</p>
<div class="ui three column grid">
<div class="column">
<div class="ui fluid card">
<div class="content">
<div class="header">In favour ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value).count() }})</div>
{% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value).exists() %}
<div class="description">
<ul style="margin-bottom:0">
{% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.IN_FAVOUR.value) %}
<li>{{ vote.voter.first_name }} {{ vote.voter.last_name }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if is_latest and revision.can_vote(request.user) %}
<button class="ui bottom attached positive button" type="submit" name="action" value="VoteInFavour">
Vote in favour
</button>
{% endif %}
</div>
</div>
<div class="column">
<div class="ui fluid card">
<div class="content">
<div class="header">Against ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value).count() }})</div>
{% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value).exists() %}
<div class="description">
<ul style="margin-bottom:0">
{% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.AGAINST.value) %}
<li>{{ vote.voter.first_name }} {{ vote.voter.last_name }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if is_latest and revision.can_vote(request.user) %}
<button class="ui bottom attached negative button" type="submit" name="action" value="VoteAgainst">
Vote against
</button>
{% endif %}
</div>
</div>
<div class="column">
<div class="ui fluid card">
<div class="content">
<div class="header">Abstentions ({{ revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value).count() }})</div>
{% if revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value).exists() %}
<div class="description">
<ul style="margin-bottom:0">
{% for vote in revision.budgetvote_set.filter(is_current=True, vote_type=import('sstreasury.models').BudgetVoteType.ABSTAIN.value) %}
<li>{{ vote.voter.first_name }} {{ vote.voter.last_name }}</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
{% if is_latest and revision.can_vote(request.user) %}
<button class="ui bottom attached secondary button" type="submit" name="action" value="VoteAbstain">
Abstain
</button>
{% endif %}
</div>
</div>
</div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
{% endif %}
{% if is_latest %}
{% if claims is not none %}
<h2>Reimbursement claims</h2>
{% if claims %}
<table class="ui celled table">
<thead>
<tr>
<th class="nine wide">Purpose</th>
<th class="four wide">Status</th>
<th class="two wide">Total</th>
<th class="one wide">View</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td>{{ claim.purpose }}</td>
<td>{{ claim.get_state_display() }}</td>
<td>{{ '${:.2f}'.format(claim.get_total()) }}</td>
<td>
<a href="{{ url('claim_view', kwargs={'id': claim.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>
</tr>
{% endfor %}
<tr>
<td style="font-weight:bold" colspan="2">Total paid:</td>
<td style="font-weight:bold">{{ '${:.2f}'.format(claims_total_paid) }}</td>
<td></td>
</tr>
</tbody>
</table>
<div class="ui warning message">
<p>This list will not include invoices, or other transactions tracked outside of Self Service.</p>
</div>
{% else %}
<p>There are no claims to display.</p>
{% endif %}
{% endif %}
{% endif %}
<div class="ui modal">
<div class="content" data-action="Submit">
<p>Are you sure you want to submit this budget for Treasury review? You will not be able to make any additional changes without withdrawing the budget.</p>
</div>
<div class="content" data-action="Endorse">
<p>Are you sure you want to give this budget Treasury endorsement?</p>
</div>
<div class="content" data-action="Comment,Endorse">
<p>Are you sure you want to give this budget Treasury endorsement?</p>
</div>
<div class="content" data-action="Return">
<p>Are you sure you want to refuse this budget Treasury endorsement and return it for re-drafting?</p>
</div>
<div class="content" data-action="Comment,Return">
<p>Are you sure you want to refuse this budget Treasury endorsement and return it for re-drafting?</p>
</div>
<div class="content" data-action="Approve">
<p>Are you sure you want to mark this budget as committee-approved?</p>
</div>
<div class="content" data-action="Comment,Approve">
<p>Are you sure you want to mark this budget as committee-approved?</p>
</div>
<div class="content" data-action="CmteReturn">
<p>Are you sure you want to refuse this budget committee approval and return it for re-drafting?</p>
</div>
<div class="content" data-action="Comment,CmteReturn">
<p>Are you sure you want to refuse this budget committee approval and return it for re-drafting?</p>
</div>
<div class="content" data-action="Withdraw">
<p>Are you sure you want to withdraw this budget from being considered for approval? The budget will be reverted to a draft.</p>
</div>
<div class="content" data-action="Cancel">
<p>Are you sure you want to cancel this budget?</p>
</div>
<div class="actions">
<div class="ui primary approve button">Continue</div>
<div class="ui cancel button">Cancel</div>
</div>
</div>
{% endif %}
</div>
<div class="four wide column">
{% if revision.expense %}
<p class="ui header">Expenses</p>
<canvas id="chartExpenses"></canvas>
{% endif %}
{% if revision.expense or revision.revenue %}
<p class="ui header">Revenue and expenses</p>
<canvas id="chartRevExp"></canvas>
{% endif %}
</div>
{% endblock %}
{% block head %}
@ -182,108 +460,57 @@
.jsgrid-header-row .jsgrid-header-cell {
text-align: center !important;
}
/* Full screen width for graphs */
@media only screen and (min-width: 768px) {
.ui.container {
width: auto;
margin-left: 64px !important;
margin-right: 64px !important;
}
}
</style>
{% endblock %}
{% block script %}
{{ super() }}
<script>
function uiSubmit(button) {
button.form.elements['action'].value = button.dataset['action'];
button.form.submit();
return false;
}
function uiConfirm(button) {
$('.ui.modal .content').hide();
$('.ui.modal .content').filter('[data-action="' + button.dataset['action'] + '"]').show();
$('.ui.modal').modal({
closable: false,
onApprove: function() {
uiSubmit(button);
}
}).modal('show');
return false;
}
</script>
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.3.0/dist/chart.umd.js" integrity="sha256-5dsP1lVzcWPn5aOwu+zs+G+TqAu9oT8NUNM0C4n3H+4=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/budget.js') }}"></script>
<script>
$('.ui.accordion').accordion();
function recalcRevTotal(args) {
//console.log(args);
var total = 0;
var totalIWT = 0;
for (var row of args.grid.data) {
total += row['Unit cost'] * row['Units'];
if (row['Unit cost'] > 0 && row['IWT']) {
totalIWT += (row['Unit cost'] - (row['Unit cost'] - 0.8) / 1.019) * row['Units'];
}
}
$(args.grid._body).find('.totalrow').remove();
if (totalIWT > 0) {
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Less IWT fees:</td>').prop('colspan', args.grid.fields.length - 1));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('($' + totalIWT.toFixed(2) + ')'));
$(args.grid._body).find('tr:last').after(totalrow);
}
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - 1));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (total - totalIWT).toFixed(2)));
$(args.grid._body).find('tr:last').after(totalrow);
}
function recalcExpTotal(args) {
var total = 0;
for (var row of args.grid.data) {
total += row['Unit cost'] * row['Units'];
}
$(args.grid._body).find('.totalrow').remove();
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Plus emergency fund:</td>').prop('colspan', args.grid.fields.length - 1));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (total * 0.05).toFixed(2)));
$(args.grid._body).find('tr:last').after(totalrow);
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - 1));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (total * 1.05).toFixed(2)));
$(args.grid._body).find('tr:last').after(totalrow);
}
// Allow floats
function FloatNumberField(config) {
jsGrid.NumberField.call(this, config);
}
FloatNumberField.prototype = new jsGrid.NumberField({
filterValue: function() {
return parseFloat(this.filterControl.val());
},
insertValue: function() {
return parseFloat(this.insertControl.val());
},
editValue: function() {
return parseFloat(this.editControl.val());
}
});
jsGrid.fields.float = FloatNumberField;
const ticketingFeeName = JSON.parse({{ import('json').dumps(import('json').dumps(settings.TICKETING_FEE_NAME))|safe }});
const ticketingFeeProportion = {{ revision.ticketing_fee_proportion }};
const ticketingFeeFixed = {{ revision.ticketing_fee_fixed }};
const editing = false;
var revenue_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.revenue))|safe }});
$('#revenue_grid').jsGrid({
width: '100%',
height: 'auto',
noDataContent: 'No entries',
data: revenue_data,
fields: [
{ name: 'Description', type: 'text', width: '55%', validate: 'required' },
{ name: 'Unit cost', type: 'float', width: '10%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '10%', validate: 'required' },
{ name: 'IWT', type: 'checkbox', width: '5%' },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit cost'] * item['Units']).toFixed(2); } },
],
onRefreshed: recalcRevTotal,
});
var expense_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.expense))|safe }});
$('#expense_grid').jsGrid({
width: '100%',
height: 'auto',
noDataContent: 'No entries',
data: expense_data,
fields: [
{ name: 'Description', type: 'text', width: '55%', validate: 'required' },
{ name: 'Unit cost', type: 'float', width: '10%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '10%', validate: 'required' },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit cost'] * item['Units']).toFixed(2); } },
],
onRefreshed: recalcExpTotal,
});
makeGrid();
makeCharts();
</script>
{% endblock %}

View File

@ -0,0 +1,212 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018-2022 Yingtong Li (RunasSudo)
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/>.
#}
{% block title %}{% if request.resolver_match.url_name == 'claim_new' %}New{% else %}Edit{% endif %} reimbursement claim{% endblock %}
{% block maincontent %}
<h1>{% if request.resolver_match.url_name == 'claim_new' %}New{% else %}Edit{% endif %} reimbursement claim</h1>
<form class="ui form" method="POST" enctype="multipart/form-data">
<div class="ui disabled inline grid field">
<label class="three wide column">ID</label>
<input class="eleven wide column" type="text" name="id" value="{{ 'RE-{}'.format(claim.id) if claim.id != None else '' }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Purpose</label>
<input class="eleven wide column" type="text" name="purpose" value="{{ claim.purpose }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Expenditure date</label>
<div class="eleven wide column">
<div class="ui calendar" id="cal_date">
<div class="ui input left icon grid">
<i class="calendar icon" style="z-index: 999;"></i>
<input class="twelve wide column" type="text" name="date" value="{{ claim.date or '' }}">
</div>
</div>
</div>
</div>
<div class="ui required grid field">
<label class="three wide column">Budget ID</label>
<input class="eleven wide column" type="text" name="budget_id" value="{{ claim.budget_id }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Submitter</label>
<div class="eleven wide column">
<div class="ui list">
<div class="item">
<i class="user circle icon"></i>
<div class="content">
<a href="mailto:{{ claim.author.email }}">
{% if claim.author.first_name %}
{{ claim.author.first_name }} {{ claim.author.last_name }}
{% else %}
{{ claim.author.email }}
{% endif %}
</a>
</div>
</div>
</div>
</div>
</div>
<div class="ui divider"></div>
<div class="ui inline grid field">
<label class="three wide column">Comments</label>
<textarea class="eleven wide column" rows="2" name="comments">{{ claim.comments }}</textarea>
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Payee name</label>
<input class="eleven wide column" type="text" name="payee_name" value="{{ claim.payee_name }}">
</div>
<div class="ui required inline grid field" style="margin-bottom: 0.5em;">
<label class="three wide column">Payee BSB</label>
<input class="eleven wide column" type="text" name="payee_bsb" value="{{ claim.payee_bsb }}">
</div>
<div class="ui inline grid field" style="margin-top: 0.5em;">
<div class="three wide column" style="padding-top: 0; padding-bottom: 0;"></div>
<div class="eleven wide column" style="padding-top: 0; padding-bottom: 0;" id="bsb_lookedup">{% if bsb_lookedup %}{{ bsb_lookedup }}{% endif %}</div>
</div>
<div class="ui required inline grid field">
<label class="three wide column">Payee account number</label>
<input class="eleven wide column" type="text" name="payee_account" value="{{ claim.payee_account }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Receipts</label>
<div class="eleven wide column">
<ul>
{% for claim_receipt in claim.claimreceipt_set.all() %}
<li><a href="{{ MEDIA_URL }}{{ claim_receipt.uploaded_file.name }}">{{ claim_receipt.uploaded_file.name.split('/')[-1] }}</a> <button class="ui mini red basic icon button" type="submit" name="submit" value="DeleteFile{{ claim_receipt.id }}" style="margin-left: 1em;" onclick="return confirm('Are you sure you want to delete this file? If you have any unsaved changes, you should save the claim first.');"><i class="trash icon"></i></button></li>
{% endfor %}
</ul>
<input type="file" name="upload_file" multiple>
</div>
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Items</label>
<div class="eleven wide column"></div>
</div>
<div id="items_grid"></div>
<input type="hidden" name="items" id="items_input">
<div class="ui divider"></div>
<div class="ui error message"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<button class="ui primary button" type="submit" name='submit' value="Save">Save as draft</button>
<input class="ui button" type="submit" name='submit' value="Save and continue editing">
{% if request.resolver_match.url_name == 'claim_edit' %}
<input class="ui right floated red button" type="submit" name='submit' value="Delete" onclick="return confirm('Are you sure you want to delete this reimbursement claim? This action is IRREVERSIBLE.');">
{% endif %}
</form>
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/semantic-ui-calendar@0.0.8/dist/calendar.min.css" integrity="sha256-KCHiPtYk/vfF5/6lDXpz5r5FuIYchVdai0fepwGft80=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.css" integrity="sha256-a/jNbtm7jpeKiXCShJ8YC+eNL9Abh7CBiYXHgaofUVs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid-theme.min.css" integrity="sha256-0rD7ZUV4NLK6VtGhEim14ZUZGC45Kcikjdcr4N03ddA=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/dragula@3.7.2/dist/dragula.min.css" integrity="sha256-iVhQxXOykHeL03K08zkxBGxDCLCuzRGGiTYf2FL6mLY=" crossorigin="anonymous">
{% endblock %}
{% block script %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/npm/semantic-ui-calendar@0.0.8/dist/calendar.min.js" integrity="sha256-Pnz4CK94A8tUiYWCfg/Ko25YZrHqOKeMS4JDXVTcVA0=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="https://cdn.jsdelivr.net/npm/dragula@3.7.2/dist/dragula.min.js" integrity="sha256-ug4bHfqHFAj2B5MESRxbLd3R3wdVMQzug2KHZqFEmFI=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/claim.js') }}"></script>
<script>
function leftpad(n) {
if (n < 10)
return '0' + n;
return '' + n;
}
$('#cal_date').calendar({
type: 'date',
formatter: {
date: function(date, settings) {
return date.getFullYear() + '-' + leftpad(date.getMonth() + 1) + '-' + leftpad(date.getDate());
}
}
});
$('.ui.form').form({
on: 'blur',
keyboardShortcuts: false,
fields: {
purpose: 'empty',
date: 'empty',
budget_id: 'empty'
},
onSuccess: function(event, fields) {
var items_data = [];
$('#items_grid .jsgrid-grid-body tr:not(.totalrow):not(.jsgrid-nodata-row)').each(function(i, el) {
var row = $(el).data('JSGridItem');
items_data.push({
'Description': row['Description'],
'Unit price': row['Unit price\n(incl GST)'],
'Units': row['Units'],
'GST-free': row['GST-free'],
});
});
$('#items_input').val(JSON.stringify(items_data));
}
});
// Interferes with jsGrid
$('.ui.form').on('keyup keypress', function(e) {
var keyCode = e.keyCode || e.which;
if (keyCode === 13) {
e.preventDefault();
return false;
}
});
var items_data = JSON.parse({{ import('json').dumps(import('json').dumps(claim.items))|safe }});
for (var row of items_data) {
row['Unit price\n(incl GST)'] = row['Unit price'];
}
var editing = true;
makeGrid();
dragula([document.querySelector('#items_grid tbody')], {
accepts: function (el, target, source, sibling) {
return sibling !== null && !sibling.classList.contains('totalrow');
},
invalid: function (el, handle) {
return el.classList.contains('totalrow');
}
});
$('input[name="payee_bsb"]').blur(function() {
$.get('{{ url('bsb_lookup') }}?bsb=' + this.value, function(data, status, xhr) {
if (data.result) {
$('#bsb_lookedup').text(data.result);
} else {
$('#bsb_lookedup').text('');
}
});
})
</script>
{% endblock %}

View File

@ -0,0 +1,117 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018–2023 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Your reimbursement claims{% endblock %}
{% macro listclaims(claims) %}
<table class="ui selectable celled table">
<thead>
<tr>
<th class="eleven wide">Purpose</th>
<th class="four wide">Status</th>
<th class="one wide">View</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td class="selectable"><a href="{{ url('claim_view', kwargs={'id': claim.id}) }}">{{ claim.purpose }}</a></td>
<td class="selectable"><a href="{{ url('claim_view', kwargs={'id': claim.id}) }}">{{ claim.get_state_display() }}</a></td>
<td>
<a href="{{ url('claim_view', kwargs={'id': claim.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endmacro %}
{% block maincontent %}
<h1>Your reimbursement claims</h1>
<form class="ui form" method="GET">
<div class="fields">
<div class="eight wide field">
<label>State</label>
<select class="ui dropdown" name="state">
<option value="all"{% if request.GET.get('state', 'all') == 'all' %} selected{% endif %}>All states</option>
{% for state in import('sstreasury.models').ClaimState %}
<option value="{{ state._value_ }}"{% if request.GET.get('state', 'all') == state._value_|string %} selected{% endif %}>{{ state.description }}</option>
{% endfor %}
</select>
</div>
<div class="five wide field">
<label>Year</label>
<input name="year" value="{{ request.GET.get('year', '') }}">
</div>
<div class="three wide field">
<label>&nbsp;</label>
<button type="submit" class="ui primary labeled icon button" style="width:100%"><i class="filter icon"></i>Filter</button>
</div>
</div>
</form>
{% if not claims_action and not claims_open and not claims_closed %}
<p>There are no reimbursement claims matching the selected criteria. To create a claim, click <a href="{{ url('claim_new') }}">Create new claim</a>.</p>
{% endif %}
{% if claims_action %}
<h2>Claims requiring action</h2>
{{ listclaims(claims_action) }}
{% endif %}
{% if claims_open %}
<h2>Open claims</h2>
{{ listclaims(claims_open) }}
{% endif %}
{% if claims_closed %}
<h2>Closed claims</h2>
{{ listclaims(claims_closed) }}
{% endif %}
<div style="text-align:center;padding-top:1em">
<div class="ui pagination menu">
{% if page.has_previous() %}
<a class="item" href="?page={{ page.previous_page_number() }}&state={{ request.GET.get('state', 'all') }}&year={{ request.GET.get('year', '') }}">&lsaquo; Prev</a>
{% endif %}
<a class="active item">Page {{ page.number }} of {{ page.paginator.num_pages }}</a>
{% if page.has_next() %}
<a class="item" href="?page={{ page.next_page_number() }}&state={{ request.GET.get('state', 'all') }}&year={{ request.GET.get('year', '') }}">Next &rsaquo;</a>
{% endif %}
</div>
</div>
{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}
{% block script %}
{{ super() }}
<script>
$('.ui.dropdown').dropdown();
</script>
{% endblock %}

View File

@ -0,0 +1,168 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
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/>.
#}
{% block title %}{{ claim.purpose }}{% endblock %}
{% block content %}
<h1>{{ claim.purpose }}</h1>
<span class="ui header">Status: {{ claim.get_state_display() }}</span>
<table class="ui mydefinition table">
<tbody>
<tr>
<td class="two wide">ID</td>
<td class="fourteen wide">RE-{{ claim.id }}</td>
</tr>
<tr>
<td>Purpose</td>
<td>{{ claim.purpose }}</td>
</tr>
<tr>
<td>Expenditure date</td>
<td>{{ claim.date }}</td>
</tr>
<tr>
<td>Submitter</td>
<td>
<div class="ui list">
<div class="item">
<i class="user circle icon"></i>
<div class="content">
<a href="mailto:{{ claim.author.email }}">
{% if claim.author.first_name %}
{{ claim.author.first_name }} {{ claim.author.last_name }}
{% else %}
{{ claim.author.email }}
{% endif %}
</a>
</div>
</div>
</div>
</td>
</tr>
<tr>
<td>Budget ID</td>
<td>
{% if budget and budget.budgetrevision_set.reverse()[0].can_view(request.user) %}
<a href="{{ url('budget_view', kwargs={'id': budget.id}) }}">{{ claim.budget_id }}</a>
{% if budget.budgetrevision_set.reverse()[0].state != import('sstreasury.models').BudgetState.APPROVED.value %}
<span data-tooltip="Budget has not been approved"><i class="orange exclamation circle icon"></i></span>
{% endif %}
{% elif request.user.groups.filter(name='Treasury').exists() %}
{{ claim.budget_id }} <span data-tooltip="Budget does not exist"><i class="red times circle icon"></i></span>
{% else %}
{{ claim.budget_id }}
{% endif %}
</td>
</tr>
<tr>
<td>Comments</td>
<td>{{ claim.comments }}</td>
</tr>
<tr>
<td>Payee</td>
<td>
<div class="ui list">
<div class="item">
<i class="user circle icon"></i>
<div class="content">{{ claim.payee_name }}</div>
</div>
<div class="item">
<i class="building icon"></i>
<div class="content">
BSB: {{ claim.payee_bsb }}
{% if bsb_lookedup %}
({{ bsb_lookedup }})
{% endif %}
</div>
</div>
<div class="item">
<i class="dollar sign icon"></i>
<div class="content">Account: {{ claim.payee_account }}</div>
</div>
</div>
</td>
</tr>
<tr>
<td>Receipts</td>
<td>
<ul>
{% for claim_receipt in claim.claimreceipt_set.all() %}
<li><a href="{{ MEDIA_URL }}{{ claim_receipt.uploaded_file.name }}">{{ claim_receipt.uploaded_file.name.split('/')[-1] }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
<tr>
<td>Items</td>
<td>
<div id="items_grid"></div>
</td>
</tr>
</tbody>
</table>
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.css" integrity="sha256-a/jNbtm7jpeKiXCShJ8YC+eNL9Abh7CBiYXHgaofUVs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid-theme.min.css" integrity="sha256-0rD7ZUV4NLK6VtGhEim14ZUZGC45Kcikjdcr4N03ddA=" crossorigin="anonymous">
<style>
/* Fix the CSS */
.ui.mydefinition.table > tbody > tr > td:first-child:not(.ignored) {
background: rgba(0,0,0,.03);
font-weight: 700;
color: rgba(0,0,0,.95);
}
.jsgrid-align-right, .jsgrid-align-right input, .jsgrid-align-right select, .jsgrid-align-right textarea {
text-align: right !important;
}
.jsgrid-cell {
padding: .5em !important;
}
.jsgrid-header-row .jsgrid-header-cell {
text-align: center !important;
}
</style>
{% endblock %}
{% block script %}
{{ super() }}
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/claim.js') }}"></script>
<script>
var items_data = JSON.parse({{ import('json').dumps(import('json').dumps(claim.items))|safe }});
for (var row of items_data) {
row['Unit price\n(incl GST)'] = row['Unit price'];
}
var editing = false;
makeGrid();
print();
</script>
{% endblock %}

View File

@ -0,0 +1,68 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018–2021 Yingtong Li (RunasSudo)
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/>.
#}
{% block title %}Claims processing{% endblock %}
{% block maincontent %}
<h1>Claims processing</h1>
{% if error %}
<div class="ui error message">
An error occurred while generating the ABA file: {{ error }}
</div>
{% endif %}
<form class="ui form" method="POST">
<button class="ui small primary labeled icon button" type="submit" name="action" value="Export"><i class="download icon"></i>Export selected to ABA</button>
<button class="ui small basic primary labeled icon button" type="submit" name="action" value="ExportXero"><i class="download icon"></i>Export selected for Xero</button>
<button class="ui small basic primary labeled icon button" type="submit" name="action" value="Pay"><i class="check icon"></i>Mark selected as paid</button>
<table class="ui celled table">
<thead>
<tr>
<th class="one wide"><input type="checkbox" onchange="$(this.form).find('.claim-checkbox').prop('checked', this.checked);"></th>
<th class="nine wide">Purpose</th>
<th class="three wide">Payee</th>
<th class="two wide">Total</th>
<th class="one wide">View</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td><input name="claim_{{ claim.id }}" type="checkbox" class="claim-checkbox"></td>
<td>{{ claim.purpose }}</td>
<td>{{ claim.payee_name }}</td>
<td>{{ '${:.2f}'.format(claim.get_total()) }}</td>
<td>
<a href="{{ url('claim_view', kwargs={'id': claim.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}

View File

@ -0,0 +1,315 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
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/>.
#}
{% block title %}{{ claim.purpose }}{% endblock %}
{% block maincontent %}
<h1>{{ claim.purpose }}</h1>
<form class="ui form" action="{{ url('claim_action', kwargs={'id': claim.id}) }}" method="POST">
<span class="ui header">Status: {{ claim.get_state_display() }}</span>
{% if claim.can_submit(request.user) %}
<button class="ui mini labeled primary icon button" data-action="Submit" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="paper plane icon"></i> Submit</button>
{% endif %}
{% if claim.can_approve(request.user) %}
<button class="ui mini labeled positive icon button" data-action="Approve" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="check icon"></i> Approve</button>
<button class="ui mini labeled basic negative icon button" data-action="Return" onclick="return uiConfirm(this);"><i class="undo icon"></i> Return for re-drafting</button>
{% endif %}
{% if claim.can_withdraw(request.user) %}
<button class="ui mini labeled basic negative icon button" data-action="Withdraw" style="margin-left: 1em;" onclick="return uiConfirm(this);"><i class="undo icon"></i> Withdraw</button>
{% endif %}
{% if claim.can_edit(request.user) %}
<a class="ui mini labeled right floated icon button" href="{{ url('claim_edit', kwargs={'id': claim.id}) }}"><i class="edit icon"></i> Edit</a>
{% endif %}
<a class="ui mini labeled right floated icon button" href="{{ url('claim_print', kwargs={'id': claim.id}) }}" target="_blank"><i class="print icon"></i> Print</a>
{% if not claim.can_edit(request.user) and claim.can_withdraw(request.user) %}
<div class="ui message">
<p>This claim has been submitted and is now awaiting processing. If you wish to edit this claim, you must first withdraw it. This will revert the claim to a draft.</p>
</div>
{% endif %}
{% if claim.state == import('sstreasury.models').ClaimState.APPROVED.value and request.user.groups.filter(name='Treasury').exists() %}
<div class="ui message">
<p>This claim has been approved and is now awaiting payment. To pay this claim, access the <a href="{{ url('claim_processing') }}">Claims processing</a> page.</p>
</div>
{% endif %}
<input type="hidden" name="action" value="">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
<table class="ui mydefinition table">
<tbody>
<tr>
<td class="two wide">ID</td>
<td class="fourteen wide">RE-{{ claim.id }}</td>
</tr>
<tr>
<td>Purpose</td>
<td>{{ claim.purpose }}</td>
</tr>
<tr>
<td>Expenditure date</td>
<td>{{ claim.date }}</td>
</tr>
<tr>
<td>Submitter</td>
<td>
<div class="ui list">
<div class="item">
<i class="user circle icon"></i>
<div class="content">
<a href="mailto:{{ claim.author.email }}">
{% if claim.author.first_name %}
{{ claim.author.first_name }} {{ claim.author.last_name }}
{% else %}
{{ claim.author.email }}
{% endif %}
</a>
</div>
</div>
</div>
</td>
</tr>
<tr>
<td>Budget ID</td>
<td>
{% if budget and budget.budgetrevision_set.reverse()[0].can_view(request.user) %}
<a href="{{ url('budget_view', kwargs={'id': budget.id}) }}">{{ claim.budget_id }}</a>
{% if budget.budgetrevision_set.reverse()[0].state != import('sstreasury.models').BudgetState.APPROVED.value %}
<span data-tooltip="Budget has not been approved"><i class="orange exclamation circle icon"></i></span>
{% endif %}
{% elif request.user.groups.filter(name='Treasury').exists() %}
{{ claim.budget_id }} <span data-tooltip="Budget does not exist"><i class="red times circle icon"></i></span>
{% else %}
{{ claim.budget_id }}
{% endif %}
</td>
</tr>
<tr>
<td>Comments</td>
<td>{{ claim.comments }}</td>
</tr>
<tr>
<td>Payee</td>
<td>
<div class="ui list">
<div class="item">
<i class="user circle icon"></i>
<div class="content">{{ claim.payee_name }}</div>
</div>
<div class="item">
<i class="building icon"></i>
<div class="content">
BSB: {{ claim.payee_bsb }}
{% if bsb_lookedup %}
({{ bsb_lookedup }})
{% endif %}
</div>
</div>
<div class="item">
<i class="dollar sign icon"></i>
<div class="content">Account: {{ claim.payee_account }}</div>
</div>
</div>
</td>
</tr>
<tr>
<td>Receipts</td>
<td>
<ul>
{% for claim_receipt in claim.claimreceipt_set.all() %}
<li><a href="{{ MEDIA_URL }}{{ claim_receipt.uploaded_file.name }}">{{ claim_receipt.uploaded_file.name.split('/')[-1] }}</a></li>
{% endfor %}
</ul>
</td>
</tr>
<tr>
<td>Items</td>
<td>
<div id="items_grid"></div>
</td>
</tr>
</tbody>
</table>
<form class="ui form" action="{{ url('claim_action', kwargs={'id': claim.id}) }}" method="POST">
<div class="required field">
<textarea rows="4" name="comment"></textarea>
</div>
<input type="hidden" name="action" value="">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<button class="ui primary button" data-action="Comment" onclick="return uiSubmit(this);">Comment</button>
{% if claim.can_approve(request.user) %}
<button class="ui right floated labeled basic negative icon button" data-action="Comment,Return" onclick="return uiConfirm(this);"><i class="undo icon"></i> Comment and return for re-drafting</button>
<button class="ui right floated labeled positive icon button" data-action="Comment,Approve" onclick="return uiConfirm(this);"><i class="check icon"></i> Comment and approve</button>
{% endif %}
</form>
<div class="ui feed">
{% for item in history %}
{% if item.__class__.__name__ == 'ClaimComment' %}
<div class="event">
<div class="label">
<i class="comment alternate outline icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> commented
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
<div class="extra text">
{{ item.content|markdown }}
</div>
</div>
</div>
{% elif item.__class__.__name__ == 'ClaimHistory' %}
<div class="event">
<div class="label">
<i class="edit icon"></i>
</div>
<div class="content">
<div class="summary">
<i class="user circle icon"></i>
{% if item.action == import('sstreasury.models').ClaimAction.CREATE.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> created the claim
{% elif item.action == import('sstreasury.models').ClaimAction.EDIT.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> edited the claim
{% elif item.action == import('sstreasury.models').ClaimAction.UPDATE_STATE.value %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> changed the state to: {{ item.get_state_display() }}
{% else %}
<a href="mailto:{{ item.author.email }}">{{ item.author.first_name }} {{ item.author.last_name }}</a> modified the claim
{% endif %}
<div class="date">
{{ localtime(item.time) }}
</div>
</div>
</div>
</div>
{% endif %}
{% endfor %}
</div>
<div class="ui modal">
<div class="content" data-action="Submit">
<p>Are you sure you want to submit this claim for Treasury approval? You will not be able to make any additional changes without withdrawing the claim.</p>
<div class="ui segment">
<h2 class="ui header">Declaration</h2>
<p>By submitting this form for processing with my MUMUS account, I agree that MUMUS Inc. will accept this communication as containing my signature for the purposes of the Electronic Transactions Acts. I certify that the information on this form is true and accurate. I acknowledge that incorrect information may result in the forfeiture of this reimbursement.</p>
<p>Under the Pay As You Go legislation and guidelines produced by the Australian Taxation Office, I state that the supply to MUMUS Inc. described on this form is wholly of a private or domestic nature for me, I have no reasonable expectation of profit or gain from the activity undertaken, and I consider that I do not meet the definition of enterprise for tax purposes. Therefore, I do not need to quote an Australian Business Number and MUMUS Inc. is not required to withhold tax from my payment.</p>
</div>
</div>
<div class="content" data-action="Approve">
<p>Are you sure you want to approve this claim?</p>
</div>
<div class="content" data-action="Comment,Approve">
<p>Are you sure you want to approve this claim?</p>
</div>
<div class="content" data-action="Return">
<p>Are you sure you want to refuse this claim and return it for re-drafting?</p>
</div>
<div class="content" data-action="Comment,Return">
<p>Are you sure you want to refuse this claim and return it for re-drafting?</p>
</div>
<div class="content" data-action="Withdraw">
<p>Are you sure you want to withdraw this claim from being considered for approval? The budget will be reverted to a draft.</p>
</div>
<div class="actions">
<div class="ui primary approve button">Continue</div>
<div class="ui cancel button">Cancel</div>
</div>
</div>
{% endblock %}
{% block head %}
{{ super() }}
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.css" integrity="sha256-a/jNbtm7jpeKiXCShJ8YC+eNL9Abh7CBiYXHgaofUVs=" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid-theme.min.css" integrity="sha256-0rD7ZUV4NLK6VtGhEim14ZUZGC45Kcikjdcr4N03ddA=" crossorigin="anonymous">
<style>
/* Fix the CSS */
.ui.mydefinition.table > tbody > tr > td:first-child:not(.ignored) {
background: rgba(0,0,0,.03);
font-weight: 700;
color: rgba(0,0,0,.95);
}
.jsgrid-align-right, .jsgrid-align-right input, .jsgrid-align-right select, .jsgrid-align-right textarea {
text-align: right !important;
}
.jsgrid-cell {
padding: .5em !important;
}
.jsgrid-header-row .jsgrid-header-cell {
text-align: center !important;
}
</style>
{% endblock %}
{% block script %}
{{ super() }}
<script>
function uiSubmit(button) {
button.form.elements['action'].value = button.dataset['action'];
button.form.submit();
return false;
}
function uiConfirm(button) {
$('.ui.modal .content').hide();
$('.ui.modal .content').filter('[data-action="' + button.dataset['action'] + '"]').show();
$('.ui.modal').modal({
closable: false,
onApprove: function() {
uiSubmit(button);
}
}).modal('show');
return false;
}
</script>
<script src="https://cdn.jsdelivr.net/npm/jsgrid@1.5.3/dist/jsgrid.min.js" integrity="sha256-lzjMTpg04xOdI+MJdjBst98bVI6qHToLyVodu3EywFU=" crossorigin="anonymous"></script>
<script src="{{ static('sstreasury/claim.js') }}"></script>
<script>
var items_data = JSON.parse({{ import('json').dumps(import('json').dumps(claim.items))|safe }});
for (var row of items_data) {
row['Unit price\n(incl GST)'] = row['Unit price'];
}
var editing = false;
makeGrid();
</script>
{% endblock %}

View File

@ -0,0 +1,3 @@
Your budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}) has been reviewed by the committee and **approved**. The expenditure shown in the budget may now commence.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,5 @@
{{ comment.author.first_name }} {{ comment.author.last_name }} made a new comment on the budget *{{ revision.name }}* (BU-{{ revision.budget.id }}):
{% if format == 'markdown' %}<div class="quote">{{ comment.content|markdown }}</div>{% else %}{{ comment.content }}{% endif %}
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
Your budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}) has been reviewed and endorsed by Treasury, and referred to the committee. The committee will determine whether or not to approve the budget at its next meeting.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
A budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}) has been endorsed by Treasury and referred to the committee for consideration at its next meeting.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
Your budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}) has been reviewed by Treasury and returned to you for re-drafting. You should make any requested changes and resubmit the budget.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
Your budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}) has been reviewed by the committee and returned to you for re-drafting. You should make any requested changes and resubmit the budget.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
Your budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}) has been submitted for Treasury review.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
A budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}) has been submitted for your review.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
This is an urgent reminder from {{ requester.first_name }} {{ requester.last_name }} to vote on a budget titled *{{ revision.name }}* (BU-{{ revision.budget.id }}), which has been endorsed by Treasury and is awaiting committee review.
{{ baseurl }}{{ url('budget_view', kwargs={'id': revision.budget.id}) }}

View File

@ -0,0 +1,3 @@
Your reimbursement claim titled *{{ claim.purpose }}* (RE-{{ claim.id }}) has been reviewed and approved by Treasury, and will be paid in the next pay cycle.
{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }}

View File

@ -0,0 +1,5 @@
{{ comment.author.first_name }} {{ comment.author.last_name }} made a new comment on the reimbursement claim *{{ claim.purpose }}* (RE-{{ claim.id }}):
{% if format == 'markdown' %}<div class="quote">{{ comment.content|markdown }}</div>{% else %}{{ comment.content }}{% endif %}
{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }}

View File

@ -0,0 +1,3 @@
Your reimbursement claim titled *{{ claim.purpose }}* (RE-{{ claim.id }}) has been paid.
{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }}

View File

@ -0,0 +1,3 @@
Your reimbursement claim titled *{{ claim.purpose }}* (RE-{{ claim.id }}) has been reviewed by Treasury and returned to you for re-drafting. You should make any requested changes and resubmit the claim.
{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }}

View File

@ -0,0 +1,3 @@
Your reimbursement claim titled *{{ claim.purpose }}* (RE-{{ claim.id }}) has been submitted for Treasury review.
{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }}

View File

@ -0,0 +1,3 @@
A reimbursement claim titled *{{ claim.purpose }}* (RE-{{ claim.id }}) has been submitted for your review.
{{ baseurl }}{{ url('claim_view', kwargs={'id': claim.id}) }}

View File

@ -1,5 +1,6 @@
# Society Self-Service
# Copyright © 2018 Yingtong Li (RunasSudo)
# Copyright © 2018–2023 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@ -16,11 +17,21 @@
from django.contrib.auth.models import User
from django.conf import settings
from django.db import models
from django.utils import timezone
from jsonfield import JSONField
from decimal import Decimal
from enum import Enum
class DescriptionEnum(Enum):
def __new__(cls, value, description):
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
class Budget(models.Model):
pass
@ -33,38 +44,334 @@ class BudgetComment(models.Model):
class Meta:
ordering = ['id']
class BudgetState(Enum):
class BudgetState(DescriptionEnum):
DRAFT = 10, 'Draft'
WITHDRAWN = 15, 'Withdrawn'
RESUBMIT = 20, 'Returned for redrafting'
AWAIT_REVIEW = 30, 'Awaiting Treasury review'
ENDORSED = 40, 'Endorsed by Treasury, awaiting committee approval'
APPROVED = 50, 'Approved'
CANCELLED = 60, 'Cancelled'
def __new__(cls, value, description):
obj = object.__new__(cls)
obj._value_ = value
obj.description = description
return obj
class BudgetAction(DescriptionEnum):
CREATE = 5, 'Created'
EDIT = 10, 'Edited'
UPDATE_STATE = 20, 'Updated state'
AUTO_APPROVE = 30, 'Automatically approved'
class BudgetRevision(models.Model):
budget = models.ForeignKey(Budget, on_delete=models.CASCADE)
name = models.CharField(max_length=100)
date = models.DateField(null=True)
date = models.DateField()
contributors = models.ManyToManyField(User, related_name='+')
cost_centre = models.CharField(max_length=100)
approver = models.CharField(max_length=100)
comments = models.TextField()
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
#state = models.IntegerField(choices=[(v.value, v.description) for v in BudgetState])
state = models.IntegerField()
event_dt = models.DateTimeField(null=True)
event_attendees = models.CharField(max_length=20, null=True)
state = models.IntegerField(choices=[(v.value, v.description) for v in BudgetState])
revenue = JSONField(default=[])
revenue_comments = models.TextField()
expense = JSONField(default=[])
expense_no_emergency_fund = models.BooleanField()
expense_comments = models.TextField()
ticketing_fee_proportion = models.FloatField()
ticketing_fee_fixed = models.FloatField()
action = models.IntegerField(choices=[(v.value, v.description) for v in BudgetAction])
class Meta:
ordering = ['id']
def copy(self):
contributors = list(self.contributors.all())
self.pk, self.id = None, None
self.save()
self.contributors.add(*contributors)
def update_state(self, user, state):
self.copy()
self.author = user
self.time = timezone.now()
self.state = state.value
self.action = BudgetAction.UPDATE_STATE.value
self.save()
def get_revenue_total(self):
total = Decimal(0)
for item in self.revenue:
try:
total += Decimal(item['Unit price']) * Decimal(item['Units'])
if item['IWT'] and item['Unit price'] > 0:
total -= (Decimal(item['Unit price']) * Decimal(self.ticketing_fee_proportion) + Decimal(self.ticketing_fee_fixed)) * item['Units']
except TypeError:
# Invalid unit price, etc.
pass
return total
def get_expense_total(self):
total = Decimal(0)
for item in self.expense:
try:
total += Decimal(item['Unit price']) * Decimal(item['Units'])
except TypeError:
# Invalid unit price, etc.
pass
if not self.expense_no_emergency_fund:
total *= Decimal('1.05')
return total
# Access control
def can_view(self, user):
if user == self.author:
return True
if user in self.contributors.all():
return True
if user.groups.filter(name='Treasury').exists():
return True
if self.state in (BudgetState.AWAIT_REVIEW.value, BudgetState.ENDORSED.value, BudgetState.APPROVED.value) and user.groups.filter(name='Committee').exists():
return True
return False
def can_edit(self, user):
# Cannot edit if cannot view
if not self.can_view(user):
return False
# No one can edit if already approved
if self.state == BudgetState.APPROVED.value:
return False
# Only Treasurer or Secretary may edit if submitted
if self.state not in (BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.WITHDRAWN.value):
if user.groups.filter(name='Treasury').exists() or user.groups.filter(name='Secretary').exists():
return True
return False
# Otherwise the submitter may edit
if user == self.author:
return True
if user in self.contributors.all():
return True
if user.groups.filter(name='Treasury').exists():
return True
# Otherwise cannot edit
return False
def can_submit(self, user):
if not self.can_edit(user):
return False
if self.state in (BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.WITHDRAWN.value):
return True
return False
def can_withdraw(self, user):
if not self.can_view(user):
return False
if user != self.author and user not in self.contributors.all() and not user.groups.filter(name='Treasury').exists():
return False
if self.state in (BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.AWAIT_REVIEW.value, BudgetState.ENDORSED.value):
return True
return False
def can_endorse(self, user):
if not self.can_edit(user):
return False
if not user.groups.filter(name='Treasury').exists():
return False
if self.state in (BudgetState.AWAIT_REVIEW.value, BudgetState.DRAFT.value, BudgetState.RESUBMIT.value, BudgetState.WITHDRAWN.value):
return True
return False
def can_return(self, user):
if self.state != BudgetState.AWAIT_REVIEW.value:
return False
return self.can_endorse(user)
def can_approve(self, user):
if not self.can_edit(user):
return False
if not user.groups.filter(name='Secretary').exists() and not user.groups.filter(name='Treasury').exists():
return False
if self.state != BudgetState.APPROVED.value:
return True
return False
def can_cmtereturn(self, user):
if self.state == BudgetState.APPROVED.value:
return False
return self.can_approve(user)
def can_cancel(self, user):
if not self.can_view(user):
return False
if not user.groups.filter(name='Secretary').exists() and not user.groups.filter(name='Treasury').exists():
return False
if self.state != BudgetState.APPROVED.value:
return False
return True
def can_vote(self, user):
if not settings.BUDGET_ENABLE_VOTING:
return False
if self.state == BudgetState.ENDORSED.value and user.groups.filter(name=self.approver).exists():
return True
return False
class BudgetVoteType(DescriptionEnum):
IN_FAVOUR = 1, 'In favour'
AGAINST = -1, 'Against'
ABSTAIN = 0, 'Abstain'
class BudgetVote(models.Model):
revision = models.ForeignKey(BudgetRevision, on_delete=models.CASCADE)
voter = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
is_current = models.BooleanField()
vote_type = models.IntegerField(choices=[(v.value, v.description) for v in BudgetVoteType])
class ClaimState(DescriptionEnum):
DRAFT = 10, 'Draft'
WITHDRAWN = 15, 'Withdrawn'
RESUBMIT = 20, 'Returned for redrafting'
AWAIT_REVIEW = 30, 'Awaiting Treasury approval'
APPROVED = 40, 'Approved by Treasury, awaiting payment'
PAID = 50, 'Paid'
class ClaimAction(DescriptionEnum):
CREATE = 5, 'Created'
EDIT = 10, 'Edited'
UPDATE_STATE = 20, 'Updated state'
class ReimbursementClaim(models.Model):
purpose = models.CharField(max_length=100)
date = models.DateField()
budget_id = models.CharField(max_length=20)
comments = models.TextField()
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState])
items = JSONField(default=[])
payee_name = models.TextField()
payee_bsb = models.CharField(max_length=7)
payee_account = models.TextField(max_length=20)
def get_total(self):
total = Decimal(0)
for item in self.items:
try:
total += Decimal(item['Unit price']) * Decimal(item['Units'])
except TypeError:
# Invalid unit price, etc.
pass
return total
def update_state(self, user, state):
self.state = state.value
self.save()
claim_history = ClaimHistory()
claim_history.claim = self
claim_history.author = user
claim_history.state = self.state
claim_history.time = timezone.now()
claim_history.action = ClaimAction.UPDATE_STATE.value
claim_history.save()
# Access control
def can_view(self, user):
if user == self.author:
return True
if user.groups.filter(name='Treasury').exists():
return True
return False
def can_edit(self, user):
# Cannot edit if cannot view
if not self.can_view(user):
return False
# No one can edit if already paid
if self.state == ClaimState.PAID.value:
return False
# Only Treasurer may edit if submitted
if self.state not in (ClaimState.DRAFT.value, ClaimState.RESUBMIT.value, ClaimState.WITHDRAWN.value):
if user.groups.filter(name='Treasury').exists():
return True
return False
# Otherwise the submitter or Treasurer may edit
if user == self.author:
return True
if user.groups.filter(name='Treasury').exists():
return True
# Otherwise cannot edit
return False
def can_submit(self, user):
if not self.can_edit(user):
return False
if self.state in (ClaimState.DRAFT.value, ClaimState.RESUBMIT.value, ClaimState.WITHDRAWN.value):
return True
return False
def can_withdraw(self, user):
if not self.can_view(user):
return False
if user != self.author and not user.groups.filter(name='Treasury').exists():
return False
if self.state in (ClaimState.AWAIT_REVIEW.value, ClaimState.APPROVED.value, ClaimState.DRAFT.value, ClaimState.RESUBMIT.value):
return True
return False
def can_approve(self, user):
if not self.can_edit(user):
return False
if not user.groups.filter(name='Treasury').exists():
return False
if self.state in (ClaimState.DRAFT.value, ClaimState.RESUBMIT.value, ClaimState.AWAIT_REVIEW.value, ClaimState.WITHDRAWN.value):
return True
return False
def can_return(self, user):
return self.can_approve(user)
class ClaimReceipt(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
uploaded_file = models.FileField(upload_to='receipt_uploads/%Y/%m/%d/')
class ClaimComment(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
time = models.DateTimeField()
content = models.TextField()
class Meta:
ordering = ['id']
class ClaimHistory(models.Model):
claim = models.ForeignKey(ReimbursementClaim, on_delete=models.CASCADE)
author = models.ForeignKey(User, on_delete=models.PROTECT, related_name='+')
state = models.IntegerField(choices=[(v.value, v.description) for v in ClaimState])
time = models.DateTimeField()
action = models.IntegerField(choices=[(v.value, v.description) for v in ClaimAction])

View File

@ -0,0 +1,211 @@
/*
Society Self-Service
Copyright © 2018-2023 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
*/
$('.ui.accordion').accordion();
var revTotal = 0;
var revTotalIWT = 0;
var expTotal = 0;
var emergency_fund_mult = 0.05;
function recalcRevTotal(args) {
revTotal = 0;
revTotalIWT = 0;
for (var row of args.grid.data) {
revTotal += row['Unit price'] * row['Units'];
if (row['Unit price'] > 0 && row['IWT']) {
revTotalIWT += (row['Unit price'] * ticketingFeeProportion + ticketingFeeFixed) * row['Units'];
}
}
$(args.grid._body).find('.totalrow').remove();
if (revTotalIWT > 0) {
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Less ticketing fees:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('($' + revTotalIWT.toFixed(2) + ')'));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
}
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (revTotal - revTotalIWT).toFixed(2)));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
}
function recalcExpTotal(args) {
expTotal = 0;
for (var row of args.grid.data) {
expTotal += row['Unit price'] * row['Units'];
}
$(args.grid._body).find('.totalrow').remove();
emergency_fund_mult = 0.05;
if ($('#expense_no_emergency_fund').length > 0 && $('#expense_no_emergency_fund').prop('checked')) {
emergency_fund_mult = 0;
}
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Plus emergency fund:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (expTotal * emergency_fund_mult).toFixed(2)));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + (expTotal * (1 + emergency_fund_mult)).toFixed(2)));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
}
// Allow floats
function FloatNumberField(config) {
jsGrid.NumberField.call(this, config);
}
FloatNumberField.prototype = new jsGrid.NumberField({
filterValue: function() {
return parseFloat(this.filterControl.val());
},
insertValue: function() {
return parseFloat(this.insertControl.val());
},
editValue: function() {
return parseFloat(this.editControl.val());
}
});
jsGrid.fields.float = FloatNumberField;
function makeGrid() {
f = [
{ name: 'Description', type: 'text', width: '50%', validate: 'required' },
{ name: 'Unit price', type: 'float', width: '12.5%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '12.5%', validate: 'required' },
{ name: 'IWT', title: ticketingFeeName, type: 'checkbox', width: '5%', insertTemplate: function() { var result = jsGrid.fields.checkbox.prototype.insertTemplate.call(this); result.prop('checked', true); return result; } },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit price'] * item['Units']).toFixed(2); } },
];
if (editing) {
f.push({ type: 'control', width: '10%', modeSwitchButton: false });
}
$('#revenue_grid').jsGrid({
width: '100%',
height: editing ? '20em' : 'auto',
inserting: editing,
editing: editing,
noDataContent: editing ? 'No entries. Click the green plus icon at the top right to add a new row.' : 'No entries',
data: revenue_data,
fields: f,
onItemUpdated: recalcRevTotal,
onRefreshed: recalcRevTotal,
});
f = [
{ name: 'Description', type: 'text', width: '50%', validate: 'required' },
{ name: 'Unit price', type: 'float', width: '12.5%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '12.5%', validate: 'required' },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit price'] * item['Units']).toFixed(2); } },
]
if (editing) {
f.push({ type: 'control', width: '10%', modeSwitchButton: false });
}
$('#expense_grid').jsGrid({
width: '100%',
height: editing ? '20em' : 'auto',
inserting: editing,
editing: editing,
noDataContent: editing ? 'No entries. Enter details above then click the green plus icon.' : 'No entries',
data: expense_data,
fields: f,
onItemUpdated: recalcExpTotal,
onRefreshed: recalcExpTotal,
});
if (!editing) {
$('.jsgrid-filter-row, .jsgrid-insert-row').attr('style', 'display: none !important;'); /* Override Semantic UI */
}
}
function makeCharts() {
// Display expense, revenue charts on budget view page
if (document.getElementById('chartExpenses')) {
new Chart(document.getElementById('chartExpenses'), {
type: 'pie',
data: {
labels: expense_data.map(e => e['Description']),
datasets: [{
label: 'Expenses',
data: expense_data.map(e => e['Unit price'] * e['Units'])
}]
},
options: {
plugins: {
tooltip: {
callbacks: {
label: i => '$' + i.parsed.toFixed(2)
}
}
}
},
});
}
if (document.getElementById('chartRevExp')) {
new Chart(document.getElementById('chartRevExp'), {
type: 'bar',
data: {
labels: ['Revenue', 'Expenses'],
datasets: [{
label: 'Budget',
data: [revTotal - revTotalIWT, (expTotal * (1 + emergency_fund_mult))],
backgroundColor: ['#36a2eb', '#ff6384']
}]
},
options: {
scales: {
y: {
beginAtZero: true
}
},
plugins: {
legend: {
display: false
},
tooltip: {
callbacks: {
label: i => '$' + i.parsed.y.toFixed(2)
}
}
}
},
});
}
}

View File

@ -0,0 +1,92 @@
/*
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
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/>.
*/
function recalcTotal(args) {
var total = 0;
var gst = 0;
for (var row of args.grid.data) {
total += row['Unit price\n(incl GST)'] * row['Units'];
if (!row['GST-free']) {
gst += (row['Unit price\n(incl GST)'] * row['Units']) / 11;
}
}
$(args.grid._body).find('.totalrow').remove();
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-style: italic;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Includes GST of:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + gst.toFixed(2)));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
var totalrow = $('<tr class="jsgrid-row totalrow" style="font-weight: bold;"></tr>');
totalrow.append($('<td class="jsgrid-cell">Total:</td>').prop('colspan', args.grid.fields.length - (editing ? 2 : 1)));
totalrow.append($('<td class="jsgrid-cell jsgrid-align-right"></td>').text('$' + total.toFixed(2)));
if (editing) {
totalrow.append($('<td class="jsgrid-cell"></td>'));
}
$(args.grid._body).find('tr:last').after(totalrow);
}
// Allow floats
function FloatNumberField(config) {
jsGrid.NumberField.call(this, config);
}
FloatNumberField.prototype = new jsGrid.NumberField({
filterValue: function() {
return parseFloat(this.filterControl.val());
},
insertValue: function() {
return parseFloat(this.insertControl.val());
},
editValue: function() {
return parseFloat(this.editControl.val());
}
});
jsGrid.fields.float = FloatNumberField;
function makeGrid() {
f = [
{ name: 'Description', type: 'text', width: '50%', validate: 'required' },
{ name: 'Unit price\n(incl GST)', type: 'float', width: '12.5%', validate: 'required', itemTemplate: function(value, item) { return '$' + value.toFixed(2); } },
{ name: 'Units', type: 'float', width: '12.5%', validate: 'required' },
{ name: 'GST-free', type: 'checkbox', width: '5%' },
{ name: 'Total', align: 'right', width: '10%', itemTemplate: function(value, item) { return '$' + (item['Unit price\n(incl GST)'] * item['Units']).toFixed(2); } },
];
if (editing) {
f.push({ type: 'control', width: '10%', modeSwitchButton: false });
}
$('#items_grid').jsGrid({
width: '100%',
height: editing ? '20em' : 'auto',
inserting: editing,
editing: editing,
noDataContent: editing ? 'No entries. Enter details above then click the green plus icon.' : 'No entries',
data: items_data,
fields: f,
onItemUpdated: recalcTotal,
onRefreshed: recalcTotal,
});
if (!editing) {
$('.jsgrid-filter-row, .jsgrid-insert-row').attr('style', 'display: none !important;'); /* Override Semantic UI */
}
}

View File

@ -22,7 +22,16 @@ urlpatterns = [
path('budgets/', views.budget_list, name='budget_list'),
path('budgets/new/', views.budget_new, name='budget_new'),
path('budgets/view/<int:id>', views.budget_view, name='budget_view'),
path('budgets/view/<int:id>/print', views.budget_print, name='budget_print'),
path('budgets/edit/<int:id>', views.budget_edit, name='budget_edit'),
path('budgets/action/<int:id>', views.budget_action, name='budget_action'),
path('claims/', views.claim_list, name='claim_list'),
path('claims/new/', views.claim_new, name='claim_new'),
path('claims/view/<int:id>', views.claim_view, name='claim_view'),
path('claims/view/<int:id>/print', views.claim_print, name='claim_print'),
path('claims/edit/<int:id>', views.claim_edit, name='claim_edit'),
path('claims/action/<int:id>', views.claim_action, name='claim_action'),
path('claims/processing', views.claim_processing, name='claim_processing'),
path('bsb_lookup', views.bsb_lookup, name='bsb_lookup'),
path('', views.index, name='treasury'),
]

View File

@ -1,5 +1,6 @@
# Society Self-Service
# Copyright © 2018 Yingtong Li (RunasSudo)
# Copyright © 2018–2023 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
@ -14,84 +15,162 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from django.conf import settings
from django.contrib.auth.decorators import login_required
from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied
from django.core.exceptions import PermissionDenied, ValidationError
from django.core.paginator import Paginator
from django.core.validators import validate_email
from django.db import transaction
from django.db.models import Q
from django.http import HttpResponse, JsonResponse
from django.shortcuts import render, redirect
from django.urls import reverse
from django.utils import timezone
from django.views import generic
from . import aba
from . import models
from . import xero
from ssmain.email import Emailer
import csv
from datetime import datetime
from decimal import Decimal
import functools
import io
import itertools
import json
import zipfile
@login_required
def index(request):
return render(request, 'sstreasury/index.html')
# HELPER DECORATORS
@login_required
def budget_list(request):
budgets_action = []
budgets_open = []
budgets_closed = []
for budget in models.Budget.objects.all():
def uses_budget(viewfunc):
@functools.wraps(viewfunc)
def func(request, id):
budget = models.Budget.objects.get(id=id)
revision = budget.budgetrevision_set.reverse()[0]
state = models.BudgetState(revision.state)
if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]:
budgets_action.append(revision)
elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]:
budgets_open.append(revision)
else:
budgets_closed.append(revision)
return viewfunc(request, budget, revision)
return func
return render(request, 'sstreasury/budget_list.html', {
'budgets_action': budgets_action,
'budgets_open': budgets_open,
'budgets_closed': budgets_closed
})
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
@login_required
def budget_view(request, id):
budget = models.Budget.objects.get(id=id)
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
if 'revision' in request.GET:
revision = budget.budgetrevision_set.get(id=int(request.GET['revision']))
else:
revision = budget.budgetrevision_set.reverse()[0]
def uses_claim(viewfunc):
@functools.wraps(viewfunc)
def func(request, id):
claim = models.ReimbursementClaim.objects.get(id=id)
return viewfunc(request, claim)
return func
history = list(itertools.chain(budget.budgetrevision_set.all(), revision.budget.budgetcomment_set.all()))
history.sort(key=lambda x: x.time, reverse=True)
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
return render(request, 'sstreasury/budget_view.html', {
'revision': revision,
'history': history,
'is_latest': 'revision' not in request.GET
})
def claim_editable(viewfunc):
@functools.wraps(viewfunc)
def func(request, claim):
if not claim.can_edit(request.user):
raise PermissionDenied
return viewfunc(request, claim)
return func
# HELPER FUNCTIONS
class FormValidationError(Exception):
def __init__(self, data, errors):
super().__init__(self)
self.data = data
self.errors = errors
def revision_from_form(budget, revision, form):
errors = []
revision.budget = budget
revision.name = form['name']
revision.date = form['date'] if form['date'] else None
if form['name']:
if len(form['name']) > 100:
errors.append('Title must be at most 100 characters')
revision.name = form['name']
else:
errors.append('A title must be specified')
if form['date']:
try:
form_date = timezone.make_aware(datetime.strptime(form['date'], '%Y-%m-%d'))
revision.date = form_date
except ValueError:
errors.append('Due date is not a valid date')
revision.date = None
else:
errors.append('A due date must be specified')
if form['event_dt']:
try:
form_event_dt = timezone.make_aware(datetime.strptime(form['event_dt'], '%Y-%m-%d %H:%M'))
revision.event_dt = form_event_dt
except ValueError:
errors.append('Event date/time is not a valid date-time')
revision.event_dt = None
else:
revision.event_dt = None
if form['event_attendees']:
if len(form['event_attendees']) > 20:
errors.append('Event attendees must be at most 20 characters')
revision.event_attendees = form['event_attendees']
else:
revision.event_attendees = None
if form['contributors']:
contributors = form['contributors'].split('\n')
try:
for contributor in contributors:
validate_email(contributor.strip())
except ValidationError:
errors.append('Contributors contains invalid data – type only valid email addresses, one per line')
else:
contributors = []
if form['cost_centre'] in settings.BUDGET_COST_CENTRES:
revision.cost_centre = form['cost_centre']
else:
errors.append('Cost centre is invalid')
if form['approver'] in dict(settings.AVAILABLE_APPROVERS):
revision.approver = form['approver']
else:
errors.append('Responsible committee is invalid')
revision.comments = form['comments']
revision.state = models.BudgetState.DRAFT.value
revision.revenue = json.loads(form['revenue'])
revision.revenue_comments = form['revenue_comments']
revision.expense = json.loads(form['expense'])
revision.expense_comments = form['expense_comments']
revision.expense_no_emergency_fund = True if form.get('expense_no_emergency_fund', False) else False
if errors:
raise FormValidationError(revision, errors)
revision.save()
contributors = form['contributors'].split('\n')
for contributor in contributors:
validate_email(contributor.strip())
for contributor in contributors:
try:
user = User.objects.get(email=contributor.strip())
@ -103,16 +182,177 @@ def revision_from_form(budget, revision, form):
return revision
def claim_from_form(claim, form, files):
claim.purpose = form['purpose']
claim.date = form['date'] if form['date'] else None
claim.budget_id = form['budget_id']
claim.comments = form['comments']
claim.items = json.loads(form['items'])
claim.payee_name = form['payee_name']
claim.payee_bsb = form['payee_bsb']
claim.payee_account = form['payee_account']
claim.save()
if files:
for f in files.getlist('upload_file'):
claim_receipt = models.ClaimReceipt()
claim_receipt.claim = claim
claim_receipt.uploaded_file = f
claim_receipt.save()
return claim
# INDEX VIEW
@login_required
def index(request):
return render(request, 'sstreasury/index.html')
# BUDGET VIEWS
@login_required
def budget_list(request):
# Filter budgets
budgets_filtered = []
for budget in models.Budget.objects.all():
revision = budget.budgetrevision_set.reverse()[0]
if not revision.can_view(request.user):
continue
if request.GET.get('state', 'all') != 'all' and str(revision.state) != request.GET.get('state', 'all'):
continue
if request.GET.get('cost_centre', 'all') != 'all' and revision.cost_centre != request.GET.get('cost_centre', 'all'):
continue
if request.GET.get('year', '') != '' and str(revision.time.year) != request.GET.get('year', ''):
continue
budgets_filtered.append(revision)
paginator = Paginator(budgets_filtered, 100)
page = paginator.get_page(int(request.GET.get('page', '1')))
# Categorise budgets
budgets_action = []
budgets_open = []
budgets_closed = []
for revision in page.object_list:
state = models.BudgetState(revision.state)
group = None
if request.user.groups.filter(name='Treasury').exists() and state == models.BudgetState.AWAIT_REVIEW:
group = budgets_action
elif request.user.groups.filter(name='Secretary').exists() and state == models.BudgetState.ENDORSED:
group = budgets_action
elif request.user.groups.filter(name='Committee').exists() and state == models.BudgetState.ENDORSED:
group = budgets_action
elif request.user in revision.contributors.all():
if state in [models.BudgetState.DRAFT, models.BudgetState.RESUBMIT]:
group = budgets_action
elif state in [models.BudgetState.AWAIT_REVIEW, models.BudgetState.ENDORSED]:
group = budgets_open
else:
group = budgets_closed
else:
if state in (models.BudgetState.APPROVED, models.BudgetState.WITHDRAWN, models.BudgetState.CANCELLED):
group = budgets_closed
else:
group = budgets_open
if group is not None:
group.append(revision)
# 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
return render(request, 'sstreasury/budget_list.html', {
'budgets_action': budgets_action,
'budgets_open': budgets_open,
'budgets_closed': budgets_closed,
'yearly_totals': yearly_totals,
'page': page
})
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
@login_required
@uses_budget
def budget_view(request, budget, revision):
if 'revision' in request.GET:
revision = budget.budgetrevision_set.get(id=int(request.GET['revision']))
if not revision.can_view(request.user):
raise PermissionDenied
history = list(itertools.chain(budget.budgetrevision_set.all(), budget.budgetcomment_set.all(), *[r.budgetvote_set.all() for r in budget.budgetrevision_set.all()]))
history.sort(key=lambda x: x.time, reverse=True)
if revision.state == models.BudgetState.APPROVED.value and 'revision' not in request.GET:
claims = models.ReimbursementClaim.objects.filter(Q(budget_id=str(budget.id)) | Q(budget_id__endswith='-{}'.format(budget.id))).all()
claims_total_paid = sum(c.get_total() for c in claims if c.state == models.ClaimState.PAID.value)
else:
claims = None
claims_total_paid = 0
return render(request, 'sstreasury/budget_view.html', {
'revision': revision,
'history': history,
'is_latest': 'revision' not in request.GET,
'claims': claims,
'claims_total_paid': claims_total_paid
})
@login_required
@uses_budget
def budget_print(request, budget, revision):
if 'revision' in request.GET:
revision = budget.budgetrevision_set.get(id=int(request.GET['revision']))
if not revision.can_view(request.user):
raise PermissionDenied
return render(request, 'sstreasury/budget_print.html', {
'revision': revision,
'is_latest': 'revision' not in request.GET
})
@login_required
def budget_new(request):
if request.method == 'POST':
with transaction.atomic():
budget = models.Budget()
budget.save()
revision = models.BudgetRevision()
revision.author = request.user
revision.time = timezone.now()
revision = revision_from_form(budget, revision, request.POST)
try:
with transaction.atomic():
budget = models.Budget()
budget.save()
revision = models.BudgetRevision()
revision.author = request.user
revision.time = timezone.now()
revision.ticketing_fee_proportion = settings.TICKETING_FEE_PROPORTION
revision.ticketing_fee_fixed = settings.TICKETING_FEE_FIXED
revision.action = models.BudgetAction.CREATE.value
revision.state = models.BudgetState.DRAFT.value
revision = revision_from_form(budget, revision, request.POST)
except FormValidationError as form_error:
return render(request, 'sstreasury/budget_edit.html', {
'revision': form_error.data,
'contributors': request.POST['contributors'],
'errors': form_error.errors
})
if request.POST['submit'] == 'Save':
return redirect(reverse('budget_view', kwargs={'id': budget.id}))
@ -125,51 +365,574 @@ def budget_new(request):
return render(request, 'sstreasury/budget_edit.html', {
'revision': revision,
'contributors': request.user.email
'contributors': request.user.email,
'errors': []
})
@login_required
def budget_edit(request, id):
@uses_budget
@budget_editable
def budget_edit(request, budget, revision):
if request.method == 'POST':
budget = models.Budget.objects.get(id=id)
revision = budget.budgetrevision_set.reverse()[0]
if request.POST['submit'] == 'Delete':
budget.delete()
return redirect(reverse('budget_list'))
if request.user not in revision.contributors.all():
raise PermissionDenied
with transaction.atomic():
revision = models.BudgetRevision()
revision.author = request.user
revision.time = timezone.now()
revision = revision_from_form(budget, revision, request.POST)
try:
with transaction.atomic():
new_revision = models.BudgetRevision()
new_revision.author = request.user
new_revision.time = timezone.now()
new_revision.ticketing_fee_proportion = settings.TICKETING_FEE_PROPORTION
new_revision.ticketing_fee_fixed = settings.TICKETING_FEE_FIXED
new_revision.action = models.BudgetAction.EDIT.value
new_revision.state = revision.state
new_revision = revision_from_form(budget, new_revision, request.POST)
except FormValidationError as form_error:
return render(request, 'sstreasury/budget_edit.html', {
'revision': form_error.data,
'contributors': request.POST['contributors'],
'errors': form_error.errors
})
if request.POST['submit'] == 'Save':
return redirect(reverse('budget_view', kwargs={'id': budget.id}))
else:
return redirect(reverse('budget_edit', kwargs={'id': budget.id}))
else:
budget = models.Budget.objects.get(id=id)
revision = budget.budgetrevision_set.reverse()[0]
return render(request, 'sstreasury/budget_edit.html', {
'revision': revision,
'contributors': '\n'.join(revision.contributors.all().values_list('email', flat=True))
'contributors': '\n'.join(revision.contributors.all().values_list('email', flat=True)),
'errors': []
})
@login_required
def budget_action(request, id):
budget = models.Budget.objects.get(id=id)
revision = budget.budgetrevision_set.reverse()[0]
@uses_budget
@budget_viewable
def budget_action(request, budget, revision):
actions = request.POST['action'].split(',')
if request.user not in revision.contributors.all():
raise PermissionDenied
if 'Comment' in actions and request.POST.get('comment', None):
with transaction.atomic():
comment = models.BudgetComment()
comment.budget = budget
comment.author = request.user
comment.time = timezone.now()
comment.content = request.POST['comment']
comment.save()
if request.POST['action'] == 'Comment':
comment = models.BudgetComment()
comment.budget = budget
comment.author = request.user
comment.time = timezone.now()
comment.content = request.POST['comment']
comment.save()
# Get users to email about the comment
users_to_email = set()
# Email Treasury
for user in User.objects.filter(groups__name='Treasury'):
if user != request.user:
users_to_email.add(user.email)
# Email contributors
for user in revision.contributors.all():
if user != request.user:
users_to_email.add(user.email)
# If endorsed budget, email committee
if revision.state == models.BudgetState.ENDORSED.value:
for user in User.objects.filter(groups__name=revision.approver):
if user != request.user:
users_to_email.add(user.email)
# Send emails
emailer = Emailer()
for email in users_to_email:
emailer.send_mail([email], 'New comment on budget: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_commented.md', {'revision': revision, 'comment': comment})
if 'Submit' in actions:
if not revision.can_submit(request.user):
raise PermissionDenied
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.AWAIT_REVIEW)
emailer = Emailer()
for user in User.objects.filter(groups__name='Treasury'):
emailer.send_mail([user.email], 'Action required: Budget submitted: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_submitted_treasurer.md', {'revision': revision})
for user in revision.contributors.all():
emailer.send_mail([user.email], 'Budget submitted: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_submitted_drafter.md', {'revision': revision})
if 'Withdraw' in actions:
if not revision.can_withdraw(request.user):
raise PermissionDenied
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.WITHDRAWN)
if 'Endorse' in actions:
if not revision.can_endorse(request.user):
raise PermissionDenied
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.ENDORSED)
emailer = Emailer()
for user in User.objects.filter(groups__name='Secretary'):
emailer.send_mail([user.email], 'Action required: Budget endorsed: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_endorsed_secretary.md', {'revision': revision})
for user in revision.contributors.all():
emailer.send_mail([user.email], 'Budget endorsed, awaiting committee approval: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_endorsed_drafter.md', {'revision': revision})
if 'Return' in actions:
if not revision.can_return(request.user):
raise PermissionDenied
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.RESUBMIT)
emailer = Emailer()
for user in revision.contributors.all():
emailer.send_mail([user.email], 'Action required: Budget returned for re-drafting: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_returned.md', {'revision': revision})
if 'Approve' in actions:
if not revision.can_approve(request.user):
return PermissionDenied
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.APPROVED)
emailer = Emailer()
for user in revision.contributors.all():
emailer.send_mail([user.email], 'Budget approved: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_approved.md', {'revision': revision})
if 'CmteReturn' in actions:
if not revision.can_cmtereturn(request.user):
return PermissionDenied
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.RESUBMIT)
emailer = Emailer()
for user in revision.contributors.all():
emailer.send_mail([user.email], 'Action required: Budget returned for re-drafting: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_returned_committee.md', {'revision': revision})
if 'Cancel' in actions:
if not revision.can_cancel(request.user):
raise PermissionDenied
with transaction.atomic():
revision.update_state(request.user, models.BudgetState.CANCELLED)
if '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})
if 'VoteInFavour' in actions or 'VoteAgainst' in actions or 'VoteAbstain' in actions:
if not revision.can_vote(request.user):
raise PermissionDenied
if 'VoteInFavour' in actions:
vote_type = models.BudgetVoteType.IN_FAVOUR
elif 'VoteAgainst' in actions:
vote_type = models.BudgetVoteType.AGAINST
elif 'VoteAbstain' in actions:
vote_type = models.BudgetVoteType.ABSTAIN
# Already exists?
if revision.budgetvote_set.filter(is_current=True, voter=request.user, vote_type=vote_type.value).exists():
# No need to create new vote
pass
else:
with transaction.atomic():
# Invalidate any existing votes
for vote in revision.budgetvote_set.filter(is_current=True, voter=request.user):
vote.is_current = False
vote.save()
# Create a new vote
vote = models.BudgetVote()
vote.revision = revision
vote.voter = request.user
vote.time = timezone.now()
vote.is_current = True
vote.vote_type = vote_type.value
vote.save()
# Check if threshold for automatic approval is reached
votes_in_favour = revision.budgetvote_set.filter(is_current=True, vote_type=models.BudgetVoteType.IN_FAVOUR.value).count()
if votes_in_favour >= dict(settings.AVAILABLE_APPROVERS)[revision.approver][1]:
# Automatically approve
revision.copy()
revision.time = timezone.now()
revision.state = models.BudgetState.APPROVED.value
revision.action = models.BudgetAction.AUTO_APPROVE.value
revision.save()
# Send emails
users_to_email = set()
for user in revision.contributors.all():
users_to_email.add(user.email)
for user in User.objects.filter(groups__name=revision.approver):
users_to_email.add(user.email)
emailer = Emailer()
for email in users_to_email:
emailer.send_mail([email], 'Budget approved: {} (BU-{})'.format(revision.name, budget.id), 'sstreasury/email/budget_approved.md', {'revision': revision})
return redirect(reverse('budget_view', kwargs={'id': budget.id}))
@login_required
def claim_list(request):
# Filter claims
claims_filtered = []
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
claims_filtered.append(claim)
paginator = Paginator(claims_filtered, 100)
page = paginator.get_page(int(request.GET.get('page', '1')))
# Categorise claims
claims_action = []
claims_open = []
claims_closed = []
for claim in page.object_list:
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():
if state in [models.ClaimState.PAID, models.ClaimState.WITHDRAWN]:
group = claims_closed
else:
group = claims_open
if group is not None:
group.append(claim)
return render(request, 'sstreasury/claim_list.html', {
'claims_action': claims_action,
'claims_open': claims_open,
'claims_closed': claims_closed,
'page': page
})
@login_required
def claim_new(request):
if request.method == 'POST':
with transaction.atomic():
claim = models.ReimbursementClaim()
claim.author = request.user
claim.time = timezone.now()
claim.state = models.BudgetState.DRAFT.value
claim = claim_from_form(claim, request.POST, request.FILES)
claim_history = models.ClaimHistory()
claim_history.claim = claim
claim_history.author = request.user
claim_history.state = claim.state
claim_history.time = timezone.now()
claim_history.action = models.ClaimAction.CREATE.value
claim_history.save()
if request.POST['submit'] == 'Save':
return redirect(reverse('claim_view', kwargs={'id': claim.id}))
else:
return redirect(reverse('claim_edit', kwargs={'id': claim.id}))
pass
else:
claim = models.ReimbursementClaim()
claim.author = request.user
return render(request, 'sstreasury/claim_edit.html', {
'claim': claim
})
@login_required
@uses_claim
@claim_viewable
def claim_view(request, claim):
history = list(itertools.chain(claim.claimhistory_set.all(), claim.claimcomment_set.all()))
history.sort(key=lambda x: x.time, reverse=True)
budget = None
if claim.budget_id:
try:
budget = models.Budget.objects.get(id=claim.budget_id.split('-')[-1])
except:
budget = None
bsb_lookedup = do_bsb_lookup(claim.payee_bsb)
return render(request, 'sstreasury/claim_view.html', {
'claim': claim,
'budget': budget,
'history': history,
'bsb_lookedup': bsb_lookedup
})
@login_required
@uses_claim
@claim_viewable
def claim_print(request, claim):
budget = None
if claim.budget_id:
try:
budget = models.Budget.objects.get(id=claim.budget_id.split('-')[-1])
except models.Budget.DoesNotExist:
budget = None
bsb_lookedup = do_bsb_lookup(claim.payee_bsb)
return render(request, 'sstreasury/claim_print.html', {
'claim': claim,
'budget': budget,
'bsb_lookedup': bsb_lookedup
})
@login_required
@uses_claim
@claim_editable
def claim_edit(request, claim):
if request.method == 'POST':
if request.POST['submit'].startswith('DeleteFile'):
file_id = int(request.POST['submit'][10:])
claim_receipt = models.ClaimReceipt.objects.get(id=file_id)
if claim_receipt.claim != claim:
raise PermissionDenied
claim_receipt.delete()
claim_receipt.uploaded_file.delete(save=False)
return redirect(reverse('claim_edit', kwargs={'id': claim.id}))
if request.POST['submit'] == 'Delete':
claim.delete()
return redirect(reverse('claim_list'))
with transaction.atomic():
claim = claim_from_form(claim, request.POST, request.FILES)
claim_history = models.ClaimHistory()
claim_history.claim = claim
claim_history.author = request.user
claim_history.state = claim.state
claim_history.time = timezone.now()
claim_history.action = models.ClaimAction.EDIT.value
claim_history.save()
if request.POST['submit'] == 'Save':
return redirect(reverse('claim_view', kwargs={'id': claim.id}))
else:
return redirect(reverse('claim_edit', kwargs={'id': claim.id}))
else:
bsb_lookedup = do_bsb_lookup(claim.payee_bsb)
return render(request, 'sstreasury/claim_edit.html', {
'claim': claim,
'bsb_lookedup': bsb_lookedup
})
@login_required
@uses_claim
@claim_viewable
def claim_action(request, claim):
actions = request.POST['action'].split(',')
if 'Comment' in actions and request.POST.get('comment', None):
with transaction.atomic():
comment = models.ClaimComment()
comment.claim = claim
comment.author = request.user
comment.time = timezone.now()
comment.content = request.POST['comment']
comment.save()
emailer = Emailer()
for user in User.objects.filter(groups__name='Treasury'):
if user != request.user:
emailer.send_mail([user.email], 'New comment on reimbursement claim: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_commented.md', {'claim': claim, 'comment': comment})
if claim.author != request.user:
emailer.send_mail([claim.author.email], 'New comment on reimbursement claim: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_commented.md', {'claim': claim, 'comment': comment})
if 'Submit' in actions:
if not claim.can_submit(request.user):
raise PermissionDenied
with transaction.atomic():
claim.update_state(request.user, models.ClaimState.AWAIT_REVIEW)
emailer = Emailer()
for user in User.objects.filter(groups__name='Treasury'):
emailer.send_mail([user.email], 'Action required: Reimbursement claim submitted: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_submitted_treasurer.md', {'claim': claim})
emailer.send_mail([claim.author.email], 'Reimbursement claim submitted: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_submitted_drafter.md', {'claim': claim})
if 'Withdraw' in actions:
if not claim.can_withdraw(request.user):
raise PermissionDenied
with transaction.atomic():
claim.update_state(request.user, models.ClaimState.WITHDRAWN)
if 'Approve' in actions:
if not claim.can_approve(request.user):
raise PermissionDenied
with transaction.atomic():
claim.update_state(request.user, models.ClaimState.APPROVED)
emailer = Emailer()
emailer.send_mail([claim.author.email], 'Claim approved, awaiting payment: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_approved.md', {'claim': claim})
if 'Return' in actions:
if not claim.can_approve(request.user):
raise PermissionDenied
with transaction.atomic():
claim.update_state(request.user, models.ClaimState.RESUBMIT)
emailer = Emailer()
emailer.send_mail([claim.author.email], 'Action required: Reimbursement claim returned for re-drafting: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_returned.md', {'claim': claim})
return redirect(reverse('claim_view', kwargs={'id': claim.id}))
@login_required
def claim_processing(request):
if not request.user.groups.filter(name='Treasury').exists():
raise PermissionDenied
if request.method == 'POST':
if request.POST['action'] == 'Export':
#claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
claims = models.ReimbursementClaim.objects.all()
claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)]
claims.sort(key=lambda c: '{}/{}{}/{}'.format(c.payee_name.strip(), c.payee_bsb.strip()[:3], c.payee_bsb.strip()[-3:], c.payee_account.strip()))
try:
aba_file = io.BytesIO()
aba.write_descriptive(aba_file, bank_name=settings.ABA_BANK_NAME, user_name=settings.ABA_USER_NAME, bank_code=settings.ABA_BANK_CODE, description='Reimburse', date=timezone.localtime(timezone.now()))
# CommBank requires only one entry per payee
num_records = 0
for _, payee_claims in itertools.groupby(claims, key=lambda c: '{}/{}{}/{}'.format(c.payee_name.strip(), c.payee_bsb.strip()[:3], c.payee_bsb.strip()[-3:], c.payee_account.strip())):
payee_claims = list(payee_claims)
reference = 'RE{}'.format(' '.join(str(c.id) for c in payee_claims))
if len(reference) > 18:
# Avoid cutting a reference number in half
if reference[14] == ' ':
reference = reference[:14] + ' etc'
else:
reference = ' '.join(reference[:14].split()[:-1]) + ' etc'
aba.write_detail(
aba_file,
dest_bsb=payee_claims[0].payee_bsb,
dest_account=payee_claims[0].payee_account,
cents=sum(c.get_total() for c in payee_claims)*100,
dest_name=payee_claims[0].payee_name[:32],
reference=reference,
src_bsb=settings.ABA_SRC_BSB,
src_account=settings.ABA_SRC_ACC,
src_name=settings.ABA_USER_NAME
)
num_records += 1
aba.write_total(aba_file, credit_cents=sum(c.get_total() for c in claims)*100, num_detail_records=num_records)
aba_file.flush()
response = HttpResponse(aba_file.getvalue(), content_type='text/plain')
response['Content-Disposition'] = 'attachment; filename="claims.aba"'
return response
except aba.ABAException as ex:
return render(request, 'sstreasury/claim_processing.html', {
'claims': claims,
'error': ex
})
if request.POST['action'] == 'ExportXero':
#claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
claims = models.ReimbursementClaim.objects.all()
claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)]
# Export CSV
with io.StringIO() as csv_file:
csv_writer = xero.new_writer(csv_file)
for claim in claims:
xero.write_claim(csv_writer, claim)
# Export resources to ZIP
with io.BytesIO() as zip_file_bytes:
with zipfile.ZipFile(zip_file_bytes, 'w') as zip_file:
zip_file.writestr('claims.csv', csv_file.getvalue())
for claim in claims:
for claim_receipt in claim.claimreceipt_set.all():
with claim_receipt.uploaded_file.open() as f:
zip_file.writestr('RE-{}/{}'.format(claim.id, claim_receipt.uploaded_file.name.split('/')[-1]), f.read())
response = HttpResponse(zip_file_bytes.getvalue(), content_type='application/zip')
response['Content-Disposition'] = 'attachment; filename="claims.zip"'
return response
if request.POST['action'] == 'Pay':
claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
claims = [c for c in claims if request.POST.get('claim_{}'.format(c.id), False)]
for claim in claims:
with transaction.atomic():
claim.update_state(request.user, models.ClaimState.PAID)
emailer = Emailer()
emailer.send_mail([claim.author.email], 'Claim paid: {} (RE-{})'.format(claim.purpose, claim.id), 'sstreasury/email/claim_paid.md', {'claim': claim})
if request.GET.get('view', '') == 'all':
claims = models.ReimbursementClaim.objects.all()
else:
claims = models.ReimbursementClaim.objects.filter(state=models.ClaimState.APPROVED.value).all()
return render(request, 'sstreasury/claim_processing.html', {
'claims': claims
})
@login_required
def bsb_lookup(request):
return JsonResponse({'result': do_bsb_lookup(request.GET.get('bsb', ''))})
def do_bsb_lookup(bsb):
bsb = (bsb or '').replace('-', '').replace(' ', '')
if len(bsb) != 6:
return None
bsb = '{}-{}'.format(bsb[:3], bsb[-3:])
with open(settings.BSB_FILE_PATH, 'r', newline='') as f:
reader = csv.reader(f)
for line in reader:
if line[0] == bsb:
return '{} - {}'.format(line[1], line[2])
return None

42
sstreasury/xero.py Normal file
View File

@ -0,0 +1,42 @@
# Society Self-Service
# Copyright © 2018–2020 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
from django.utils import timezone
import csv
def new_writer(f):
writer = csv.DictWriter(f, ['*ContactName', 'EmailAddress', 'POAddressLine1', 'POAddressLine2', 'POAddressLine3', 'POAddressLine4', 'POCity', 'PORegion', 'POPostalCode', 'POCountry', '*InvoiceNumber', '*InvoiceDate', '*DueDate', 'InventoryItemCode', 'Description', '*Quantity', '*UnitAmount', '*AccountCode', '*TaxType', 'TrackingName1', 'TrackingOption1', 'TrackingName2', 'TrackingOption2', 'Currency'])
writer.writeheader()
return writer
def write_row(writer, d):
writer.writerow(d)
def write_claim(writer, claim):
for i, item in enumerate(claim.items):
write_row(writer, {
'*ContactName': claim.payee_name,
'*InvoiceNumber': 'RE-{}'.format(claim.id),
'*InvoiceDate': timezone.now().strftime('%d/%m/%Y'),
'*DueDate': timezone.now().strftime('%d/%m/%Y'),
'Description': '{} - {}'.format(claim.purpose, item['Description']) if i == 0 else item['Description'],
'*Quantity': str(item['Units']),
'*UnitAmount': str(item['Unit price']),
#'*AccountCode': '850', # Suspense
'*AccountCode': 'EVT-E',
'*TaxType': 'GST Free Expenses' if item['GST-free'] else 'GST on Expenses',
})