Compare commits
115 Commits
feat-membe
...
master
Author | SHA1 | Date | |
---|---|---|---|
ec370b47cd | |||
d967a548a4 | |||
bd43db4e21 | |||
603e276bac | |||
8d3a7b7ed0 | |||
4b3314a0f4 | |||
1217770900 | |||
8a3a09d8ab | |||
490faa8147 | |||
7f6bf1b07f | |||
5994cd38b6 | |||
e098ed4f01 | |||
4f84e8a99f | |||
5cbb872c8e | |||
2b976fd19e | |||
0622ac2a89 | |||
faa81ca803 | |||
9c0d7122f6 | |||
8cce90f83b | |||
22d9525daa | |||
67060e0f04 | |||
5059c5f793 | |||
98a163f63a | |||
5c8c4750a5 | |||
7d337c92d1 | |||
6be52fb718 | |||
44ba18d22e | |||
057a97b33a | |||
2dde94483f | |||
066b461769 | |||
802ff7343f | |||
65076f0165 | |||
7299d7d414 | |||
4aef60b99b | |||
b7d6d0c5d4 | |||
ae128d209c | |||
5bf2f310d6 | |||
eb46c82ae1 | |||
1344f612f8 | |||
e7e39ef66f | |||
b2913eac9d | |||
10f0cd4121 | |||
8d31fa1af6 | |||
e28a814edf | |||
a1aa2423ad | |||
74d4c7b333 | |||
bbd775789f | |||
787b16733d | |||
435aa15d17 | |||
62119cc83c | |||
fc1bb22dc4 | |||
3a49607b83 | |||
3168b52479 | |||
fbe0396a8e | |||
b725b46f2f | |||
187f3a9b7c | |||
1cd09562a9 | |||
0dc5923429 | |||
17637a4cfd | |||
e69efdc816 | |||
28eb72ef81 | |||
ecfa4bdc2b | |||
dbe1eb988c | |||
f091b20773 | |||
1975c48273 | |||
0da9bebcae | |||
1324195a1a | |||
e5bbcaabbb | |||
a170d1ea89 | |||
16f859cb6e | |||
83ef0c5154 | |||
95bc4c3f2a | |||
eb411a78a2 | |||
e5803e1b41 | |||
005d1980ae | |||
6df1fe78a4 | |||
4e1e9a611e | |||
a9021faedc | |||
392e26636f | |||
f32510d6a3 | |||
43fd262430 | |||
d4419792b2 | |||
858eb0564f | |||
91dddaf694 | |||
f46f966bbb | |||
cdb3518b28 | |||
77102d7203 | |||
2936433689 | |||
e49d2d963a | |||
96e45c0c5c | |||
8ef85addd0 | |||
984ed2432d | |||
b09b8e2660 | |||
c4eb45514f | |||
b70ef9b8f6 | |||
f3287e127a | |||
9ff988d2e9 | |||
9430c4cf98 | |||
cb45c5b63a | |||
acd26e7160 | |||
487c2f3650 | |||
0d44b8ec78 | |||
9e1cc75287 | |||
a43c7da302 | |||
69a7c052c1 | |||
a0d6164d95 | |||
1afc3f3db7 | |||
2a5bf7ad93 | |||
2d9ea3b311 | |||
2ca59c0684 | |||
ff29819ca1 | |||
1e3ff7475a | |||
664a31500f | |||
97794b521f | |||
3193d06891 |
1
.gitignore
vendored
1
.gitignore
vendored
@ -8,5 +8,4 @@ settings.py
|
||||
**/migrations/**
|
||||
!**/migrations/__init__.py
|
||||
|
||||
static
|
||||
promo_uploads
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
27
selfserv/mdx_urlize.COPYING
Normal file
27
selfserv/mdx_urlize.COPYING
Normal 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
101
selfserv/mdx_urlize.py
Normal 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()
|
@ -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',
|
||||
|
@ -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
105
ssmain/email.py
Normal 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)
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
© Copyright {{ import('datetime').datetime.now().strftime('%Y') }} MUMUS Inc. All Rights Reserved.<br>
|
||||
© 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>
|
48
ssmain/jinja2/ssmain/email/rendered.html
Normal file
48
ssmain/jinja2/ssmain/email/rendered.html
Normal 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>
|
||||
 
|
||||
</th>
|
||||
<th class="expander"></th>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
{% endblock content %}
|
38
ssmain/management/commands/addgroup.py
Normal file
38
ssmain/management/commands/addgroup.py
Normal 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)
|
29
ssmain/management/commands/find_duplicate_users.py
Normal file
29
ssmain/management/commands/find_duplicate_users.py
Normal 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)
|
71
ssmain/static/ssmain/main.css
Normal file
71
ssmain/static/ssmain/main.css
Normal 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;
|
||||
}
|
||||
}
|
@ -1 +0,0 @@
|
||||
../../../../sspromotions/jinja2/sspromotions/email/base.html
|
@ -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 }}.
|
@ -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>
|
||||
 
|
||||
</th>
|
||||
<th class="expander"></th>
|
||||
</tr>
|
||||
</table>
|
||||
</th>
|
||||
</tr>
|
||||
{% endblock content %}
|
@ -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 }}.
|
@ -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>
|
29
ssmembership/jinja2/ssmembership/email/renew.txt
Normal file
29
ssmembership/jinja2/ssmembership/email/renew.txt
Normal 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 }}.
|
@ -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 %}
|
@ -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>
|
||||
|
@ -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 %}
|
@ -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
|
@ -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>
|
@ -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 }}">
|
@ -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 %}
|
116
ssmembership/jinja2/ssmembership/signup/index.html
Normal file
116
ssmembership/jinja2/ssmembership/signup/index.html
Normal 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 %}
|
29
ssmembership/management/commands/find_duplicate_members.py
Normal file
29
ssmembership/management/commands/find_duplicate_members.py
Normal 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)
|
75
ssmembership/management/commands/send_renewal_email.py
Normal file
75
ssmembership/management/commands/send_renewal_email.py
Normal 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)
|
@ -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),
|
||||
)
|
47
ssmembership/management/commands/sendmdemail.py
Normal file
47
ssmembership/management/commands/sendmdemail.py
Normal 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', {})
|
@ -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])
|
@ -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()
|
@ -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:
|
||||
|
@ -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()
|
@ -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'),
|
||||
]
|
||||
|
@ -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')
|
||||
|
@ -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 %}
|
||||
|
||||
|
@ -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() }}">‹ 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 ›</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
{% extends 'sspromotions/email/base.html' %}
|
||||
{% extends 'ssmain/email/base.html' %}
|
||||
|
||||
{#
|
||||
Society Self-Service
|
||||
|
@ -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)
|
||||
|
||||
|
@ -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()
|
||||
|
@ -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'),
|
||||
]
|
||||
|
@ -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
97
sstreasury/aba.py
Normal 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')
|
@ -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 %}
|
||||
|
@ -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 %}
|
||||
|
@ -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> </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', '') }}">‹ 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 ›</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 %}
|
||||
|
171
sstreasury/jinja2/sstreasury/budget_print.html
Normal file
171
sstreasury/jinja2/sstreasury/budget_print.html
Normal 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 %}
|
@ -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 %}
|
||||
|
212
sstreasury/jinja2/sstreasury/claim_edit.html
Normal file
212
sstreasury/jinja2/sstreasury/claim_edit.html
Normal 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 %}
|
117
sstreasury/jinja2/sstreasury/claim_list.html
Normal file
117
sstreasury/jinja2/sstreasury/claim_list.html
Normal 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> </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', '') }}">‹ 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 ›</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block head %}
|
||||
{{ super() }}
|
||||
{% endblock %}
|
||||
|
||||
{% block script %}
|
||||
{{ super() }}
|
||||
|
||||
<script>
|
||||
$('.ui.dropdown').dropdown();
|
||||
</script>
|
||||
{% endblock %}
|
168
sstreasury/jinja2/sstreasury/claim_print.html
Normal file
168
sstreasury/jinja2/sstreasury/claim_print.html
Normal 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 %}
|
68
sstreasury/jinja2/sstreasury/claim_processing.html
Normal file
68
sstreasury/jinja2/sstreasury/claim_processing.html
Normal 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 %}
|
315
sstreasury/jinja2/sstreasury/claim_view.html
Normal file
315
sstreasury/jinja2/sstreasury/claim_view.html
Normal 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 %}
|
3
sstreasury/jinja2/sstreasury/email/budget_approved.md
Normal file
3
sstreasury/jinja2/sstreasury/email/budget_approved.md
Normal 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}) }}
|
5
sstreasury/jinja2/sstreasury/email/budget_commented.md
Normal file
5
sstreasury/jinja2/sstreasury/email/budget_commented.md
Normal 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}) }}
|
@ -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}) }}
|
@ -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}) }}
|
3
sstreasury/jinja2/sstreasury/email/budget_returned.md
Normal file
3
sstreasury/jinja2/sstreasury/email/budget_returned.md
Normal 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}) }}
|
@ -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}) }}
|
@ -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}) }}
|
@ -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}) }}
|
@ -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}) }}
|
3
sstreasury/jinja2/sstreasury/email/claim_approved.md
Normal file
3
sstreasury/jinja2/sstreasury/email/claim_approved.md
Normal 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}) }}
|
5
sstreasury/jinja2/sstreasury/email/claim_commented.md
Normal file
5
sstreasury/jinja2/sstreasury/email/claim_commented.md
Normal 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}) }}
|
3
sstreasury/jinja2/sstreasury/email/claim_paid.md
Normal file
3
sstreasury/jinja2/sstreasury/email/claim_paid.md
Normal 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}) }}
|
3
sstreasury/jinja2/sstreasury/email/claim_returned.md
Normal file
3
sstreasury/jinja2/sstreasury/email/claim_returned.md
Normal 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}) }}
|
@ -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}) }}
|
@ -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}) }}
|
@ -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])
|
||||
|
211
sstreasury/static/sstreasury/budget.js
Normal file
211
sstreasury/static/sstreasury/budget.js
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
92
sstreasury/static/sstreasury/claim.js
Normal file
92
sstreasury/static/sstreasury/claim.js
Normal 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 */
|
||||
}
|
||||
}
|
@ -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'),
|
||||
]
|
||||
|
@ -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
42
sstreasury/xero.py
Normal 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',
|
||||
})
|
Loading…
Reference in New Issue
Block a user