Compare commits

..

10 Commits

78 changed files with 1371 additions and 4369 deletions

1
.gitignore vendored
View File

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

View File

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

View File

@ -1,5 +1,5 @@
# Society Self-Service # Society Self-Service
# Copyright © 2018-2023 Yingtong Li (RunasSudo) # Copyright © 2018 Yingtong Li (RunasSudo)
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -22,13 +22,10 @@ from django.conf import settings
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from jinja2 import Environment, select_autoescape from jinja2 import Environment, Markup, select_autoescape
from markupsafe import Markup
import importlib import importlib
from .mdx_urlize import UrlizeExtension
def environment(**options): def environment(**options):
options['autoescape'] = select_autoescape( options['autoescape'] = select_autoescape(
disabled_extensions=('txt',), disabled_extensions=('txt',),
@ -45,6 +42,6 @@ def environment(**options):
'MEDIA_URL': settings.MEDIA_URL, 'MEDIA_URL': settings.MEDIA_URL,
}) })
env.filters.update({ env.filters.update({
'markdown': lambda x: Markup(markdown.markdown(x, extensions=['nl2br', UrlizeExtension()])) 'markdown': lambda x: Markup(markdown.markdown(x, extensions=['nl2br', 'mdx_urlize']))
}) })
return env return env

View File

@ -1,27 +0,0 @@
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.

View File

@ -1,101 +0,0 @@
# Society Self-Service
# Copyright © 2018-2020 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
#
# Adapted from:
# Copyright (c) 2014 Rowan Nairn. All rights reserved. Licensed under the 2-clause BSD licence
# https://github.com/r0wb0t/markdown-urlize
"""A more liberal autolinker
Inspired by Django's urlize function.
Positive examples:
>>> import markdown
>>> md = markdown.Markdown(extensions=['urlize'])
>>> md.convert('http://example.com/')
u'<p><a href="http://example.com/">http://example.com/</a></p>'
>>> md.convert('go to http://example.com')
u'<p>go to <a href="http://example.com">http://example.com</a></p>'
>>> md.convert('example.com')
u'<p><a href="http://example.com">example.com</a></p>'
>>> md.convert('example.net')
u'<p><a href="http://example.net">example.net</a></p>'
>>> md.convert('www.example.us')
u'<p><a href="http://www.example.us">www.example.us</a></p>'
>>> md.convert('(www.example.us/path/?name=val)')
u'<p>(<a href="http://www.example.us/path/?name=val">www.example.us/path/?name=val</a>)</p>'
>>> md.convert('go to <http://example.com> now!')
u'<p>go to <a href="http://example.com">http://example.com</a> now!</p>'
Negative examples:
>>> md.convert('del.icio.us')
u'<p>del.icio.us</p>'
"""
import markdown
# Global Vars
URLIZE_RE = '(%s)' % '|'.join([
r'<(?:f|ht)tps?://[^>]*>',
r'\b(?:f|ht)tps?://[^)<>\s]+[^.,)<>\s]',
r'\bwww\.[^)<>\s]+[^.,)<>\s]',
r'[^(<\s]+\.(?:(?:com|net|org)(?:\.au)?)\b',
])
class UrlizePattern(markdown.inlinepatterns.Pattern):
""" Return a link Element given an autolink (`http://example/com`). """
def handleMatch(self, m):
url = m.group(2)
if url.startswith('<'):
url = url[1:-1]
text = url
if not url.split('://')[0] in ('http','https','ftp'):
if '@' in url and not '/' in url:
url = 'mailto:' + url
else:
url = 'http://' + url
el = markdown.util.etree.Element("a")
el.set('href', url)
el.text = markdown.util.AtomicString(text)
return el
class UrlizeExtension(markdown.Extension):
""" Urlize Extension for Python-Markdown. """
def extendMarkdown(self, md, md_globals):
""" Replace autolink with UrlizePattern """
md.inlinePatterns['autolink'] = UrlizePattern(URLIZE_RE, md)
def makeExtension(*args, **kwargs):
return UrlizeExtension(*args, **kwargs)
if __name__ == "__main__":
import doctest
doctest.testmod()

View File

@ -1,6 +1,5 @@
# Society Self-Service # Society Self-Service
# Copyright © 2018-2023 Yingtong Li (RunasSudo) # Copyright © 2018-2019 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -21,10 +20,8 @@ BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
SECRET_KEY = None # IMPORTANT: Set this to a secret string SECRET_KEY = None # IMPORTANT: Set this to a secret string
SECRET_KEY_MEMBERSIG = None # IMPORTANT: Set this to a secret string
DEBUG = True DEBUG = True
EMAIL_DEBUG = False
ALLOWED_HOSTS = [] ALLOWED_HOSTS = []
@ -33,36 +30,11 @@ PROMO_LOGO_URL = 'https://placehold.it/2000x500'
PROMO_LOGO_LINK = 'https://example.com' PROMO_LOGO_LINK = 'https://example.com'
PROMO_GROUPS_MANDATORY = ['All Years'] 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 # Application definition
INSTALLED_APPS = [ INSTALLED_APPS = [
'ssmain', 'ssmain',
'sstreasury', 'sstreasury',
'ssmembership',
'sspromotions', 'sspromotions',
'django.contrib.admin', 'django.contrib.admin',
'django.contrib.auth', 'django.contrib.auth',
@ -150,6 +122,7 @@ AUTHENTICATION_BACKENDS = (
) )
LOGIN_URL = 'login' LOGIN_URL = 'login'
LOGIN_REDIRECT_URL = 'index' LOGIN_REDIRECT_URL = 'index'
SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = None # FIXME SOCIAL_AUTH_GOOGLE_OAUTH2_KEY = None # FIXME
SOCIAL_AUTH_GOOGLE_OAUTH2_SECRET = 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 #SOCIAL_AUTH_GOOGLE_OAUTH2_AUTH_EXTRA_ARGUMENTS = {'hd': 'monash.edu'} # To restrict to a particular domain
@ -160,7 +133,7 @@ GOOGLE_CALENDAR_ID = None # FIXME
AWS_KEY_ID = None # FIXME AWS_KEY_ID = None # FIXME
AWS_SECRET = None # FIXME AWS_SECRET = None # FIXME
AWS_REGION = 'us-east-1' AWS_REGION = 'us-east-1'
AWS_SENDER_EMAIL = 'example@example.com' AWS_SENDER_EMAIL = 'postmaster@example.com' # FIXME
RATELIMIT_KEY = 'ip' # https://django-ratelimit.readthedocs.io/en/stable/keys.html#common-keys e.g. 'header:CF-Connecting-IP' RATELIMIT_KEY = 'ip' # https://django-ratelimit.readthedocs.io/en/stable/keys.html#common-keys e.g. 'header:CF-Connecting-IP'
@ -169,7 +142,6 @@ SOCIAL_AUTH_PIPELINE = (
'social_core.pipeline.social_auth.social_uid', 'social_core.pipeline.social_auth.social_uid',
'social_core.pipeline.social_auth.social_user', 'social_core.pipeline.social_auth.social_user',
'social_core.pipeline.user.get_username', 'social_core.pipeline.user.get_username',
'social_core.pipeline.social_auth.associate_by_email',
'social_core.pipeline.user.create_user', 'social_core.pipeline.user.create_user',
'social_core.pipeline.social_auth.associate_user', 'social_core.pipeline.social_auth.associate_user',
'social_core.pipeline.social_auth.load_extra_data', 'social_core.pipeline.social_auth.load_extra_data',

View File

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

View File

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

View File

@ -21,20 +21,46 @@
{% block head %} {% block head %}
{{ super() }} {{ super() }}
<link rel="stylesheet" type="text/css" href="{{ static('ssmain/main.css') }}">
{% if request.resolver_match.view_name == 'index' %}
<style type="text/css"> <style type="text/css">
.masthead.segment { .masthead.segment {
{% if request.resolver_match.view_name == 'index' %}
min-height: 700px; min-height: 700px;
{% endif %}
padding: 1em 0em;
} }
.masthead h1.ui.header { .masthead h1.ui.header {
{% if request.resolver_match.view_name == 'index' %}
margin-top: 3em; margin-top: 3em;
margin-bottom: 0em; 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 %} {% endblock %}
{% block body %} {% block body %}

View File

@ -1,48 +0,0 @@
{% extends 'ssmain/email/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block content %}
<tr> <!-- main Email content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<td>
{{ email_content }}
</td>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
<tr> <!-- This container adds whitespace gap at the bottom of main content -->
<th class="small-12 large-12 columns first last">
<table>
<tr>
<th>
&#xA0;
</th>
<th class="expander"></th>
</tr>
</table>
</th>
</tr>
{% endblock content %}

View File

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

View File

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

View File

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

View File

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

View File

@ -1,8 +1,8 @@
{% extends 'ssmain/email/base.html' %} {% extends 'ssmembership/email/base.html' %}
{# {#
Society Self-Service Society Self-Service
Copyright © 2018-2020 Yingtong Li (RunasSudo) Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence) Design by SendWithUs (Apache 2.0 licence)
@ -28,13 +28,16 @@
<th> <th>
<b><h5>Membership renewal</h5></b> <b><h5>Membership renewal</h5></b>
<p>Dear {{ name }},</p> <p>Dear {{ name }},</p>
<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> <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>
<div class="button" style="margin-bottom:1em;"> <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>
<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 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>
</div> </div>
<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> <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 want to renew your membership, or you are no longer a Monash medical student, simply ignore this email.</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 at <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, 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>
<th class="expander"></th> <th class="expander"></th>
</tr> </tr>

View File

@ -0,0 +1,35 @@
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
Dear {{ name }},
From 2019, {{ import('django.conf').settings.ORG_NAME }} is required by law to review its membership annually. You can renew your membership for free by going to the link below or visiting {{ baseurl }}{{ url('mimport_index') }}. The process is very quick and should take less than a minute.
By making sure your details are up to date, you'll also be able to receive personalised weekly emails with relevant news and events from around the Monash Medicine community.
Renew membership for free:
{{ baseurl }}{{ renew_url }}
If you do not renew your membership by **20 March 2019**, your membership will expire, and you will not be able to buy tickets to {{ import('django.conf').settings.ORG_NAME }} events at member prices or run for election within {{ import('django.conf').settings.ORG_NAME }} without paying a membership fee. Please make sure to renew your membership by 20 March 2019 to avoid this.
If you do not want to renew your membership, or you are no longer a Monash medical student, simply ignore this email.
If you encounter any issues renewing your membership, or have any other questions, please contact the Secretary, Yingtong Li, at {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.
Please note that emails are being sent in stages. If other students have not received this email, please let them know that this is normal, and they should receive their email within 7 days. Otherwise, contact {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.

View File

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

View File

@ -0,0 +1,33 @@
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
Design by SendWithUs (Apache 2.0 licence)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
Dear {{ name }},
{% if purchased %}Thank you for your recent purchase of a {{ import('django.conf').settings.ORG_NAME }} membership.{% else %}Thank you for your recent purchase of {{ import('django.conf').settings.ORG_NAME }} tickets or merchandise. Your purchase entitles you to membership of {{ import('django.conf').settings.ORG_NAME }} at no additional cost.{% endif %} You can activate your membership now by going to the link below or visiting {{ baseurl }}{{ url('monboard_index') }}. The process is very quick and should take less than a minute.
By activating your membership, you'll be able to purchase future tickets at discounted member prices, nominate for election to the {{ import('django.conf').settings.ORG_NAME }} committee, and receive personalised weekly emails with relevant news and events from around the Monash Medicine community.
Activate membership now:
{{ baseurl }}{{ renew_url }}
If you do not want to activate your membership, or you are not a Monash medical student, simply ignore this email.
If you encounter any issues activating your membership, or have any other questions, please contact the Secretary, Yingtong Li, at {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.
Please note that emails are being sent in stages. If other students have not received this email, please let them know that this is normal, and they should receive their email within 7 days. Otherwise, contact {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.

View File

@ -1,29 +0,0 @@
{#
Society Self-Service
Copyright © 2018-2020 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
Dear {{ name }},
{{ import('django.conf').settings.ORG_NAME }} is required by law to review its membership annually. You can renew your membership for free by going to the link below or visiting {{ baseurl }}{{ url('renew_index') }}. The process is very quick and should take less than a minute.
Renew membership now:
{{ baseurl }}{{ renew_url }}
If you do not renew your membership by **31 March {{ import('datetime').datetime.now().strftime('%Y') }}**, your membership will expire, and you will not be able to buy tickets to MUMUS events at member prices or run for election within MUMUS without becoming a member again. Please make sure to renew your membership by 31 March {{ import('datetime').datetime.now().strftime('%Y') }} to avoid this.
If you do not want to renew your membership, or you are no longer a Monash medical student, simply ignore this email.
If you encounter any issues renewing your membership, or have any other questions, please contact the Secretary at {{ import('django.conf').settings.AWS_SENDER_EMAIL }}.

View File

@ -2,7 +2,7 @@
{# {#
Society Self-Service Society Self-Service
Copyright © 2018-2020 Yingtong Li (RunasSudo) Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU Affero General Public License as published by

View File

@ -0,0 +1,47 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Membership renewal{% endblock %}
{% block content %}
<h1>Membership renewal</h1>
<p>To renew an existing membership, please enter your details below:</p>
<form class="ui form" method="POST" action="{{ url('mimport_search') }}">
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>
<div class="nine wide column">
<input type="text" name="student_id" placeholder="28000000">
</div>
</div>
<div class="ui required inline grid field">
<label class="three wide column">Email</label>
<div class="nine wide column">
<input type="text" name="email" placeholder="abcd0001@student.monash.edu">
<div style="margin-top: 1.5em;">Enter the email address that is registered with MUMUS. This is the email which currently receives the MUMUS Bulletin. For most people, this will be your Monash student email; however, some people may have been registered using personal emails.</div>
</div>
</div>
<div class="ui divider"></div>
<div class="ui error message"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Continue">
</form>
{% endblock %}

View File

@ -2,7 +2,7 @@
{# {#
Society Self-Service Society Self-Service
Copyright © 2018-2022 Yingtong Li (RunasSudo) Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU Affero General Public License as published by
@ -24,11 +24,11 @@
<h1>Membership renewal</h1> <h1>Membership renewal</h1>
{% if not member %} {% if not member %}
<p>The details you entered do not match our records. <a href="{{ url('renew_index') }}">Click here</a> to try again.</p> <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>
{% else %} {% else %}
<p>Please check the following details and update them if necessary:</p> <p>Please check the following details and update them if necessary:</p>
<form class="ui form" method="POST" action="{{ url('renew_save') }}"> <form class="ui form" method="POST" action="{{ url('mimport_save') }}">
{% if errors %} {% if errors %}
<div class="ui visible error message"><ul> <div class="ui visible error message"><ul>
{% for error in errors %} {% for error in errors %}
@ -40,13 +40,9 @@
<label class="three wide column">Student ID</label> <label class="three wide column">Student ID</label>
<input class="nine wide column" type="text" name="student_id" value="{{ member.student_id }}"> <input class="nine wide column" type="text" name="student_id" value="{{ member.student_id }}">
</div> </div>
<div class="ui {% if member.email.endswith('@student.monash.edu') %}disabled{% else %}required{% endif %} inline grid field"> <div class="ui required inline grid field">
<label class="three wide column">Student email</label> <label class="three wide column">Student email</label>
{% 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 }}"> <input class="nine wide column" type="text" name="email" value="{{ member.email }}">
{% endif %}
</div> </div>
<div class="ui divider"></div> <div class="ui divider"></div>
<div class="ui required inline grid field"> <div class="ui required inline grid field">
@ -79,7 +75,6 @@
<option value="1">Yes, I am an MSA member</option> <option value="1">Yes, I am an MSA member</option>
</select> </select>
</div> </div>
{% if 'sspromotions' in settings.INSTALLED_APPS %}
<div class="ui divider"></div> <div class="ui divider"></div>
<h2>MUMUS Mail</h2> <h2>MUMUS Mail</h2>
@ -97,7 +92,7 @@
{% if group.subscribable %} {% if group.subscribable %}
<div class="field" style="display: inline; margin-right: 1em;"> <div class="field" style="display: inline; margin-right: 1em;">
<div class="ui checkbox"> <div class="ui checkbox">
<input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}" checked> <input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}">
<label for="bulletin_group_{{ group.id }}">{{ group.name }}</label> <label for="bulletin_group_{{ group.id }}">{{ group.name }}</label>
</div> </div>
</div> </div>
@ -106,7 +101,6 @@
<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> <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> </div>
{% endif %}
<div class="ui divider"></div> <div class="ui divider"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}"> <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input type="hidden" name="email_orig" value="{{ email_orig }}"> <input type="hidden" name="email_orig" value="{{ email_orig }}">

View File

@ -24,6 +24,7 @@
{% if not member %} {% if not member %}
<h1>No membership records</h1> <h1>No membership records</h1>
<p>This email address is not associated with a current membership.</p> <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 %} {% else %}
<h1>Membership details</h1> <h1>Membership details</h1>
@ -32,10 +33,6 @@
<label class="three wide column">Member number</label> <label class="three wide column">Member number</label>
<div class="nine wide column">{{ member.id }}</div> <div class="nine wide column">{{ member.id }}</div>
</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 divider"></div>
<div class="ui required inline grid field"> <div class="ui required inline grid field">
<label class="three wide column">Student ID</label> <label class="three wide column">Student ID</label>

View File

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

View File

@ -2,7 +2,7 @@
{# {#
Society Self-Service Society Self-Service
Copyright © 2018-2020 Yingtong Li (RunasSudo) Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify 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 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/>. along with this program. If not, see <https://www.gnu.org/licenses/>.
#} #}
{% block title %}Membership renewal{% endblock %} {% block title %}Membership activation{% endblock %}
{% block content %} {% block content %}
<h1>Membership renewal</h1> <h1>Membership activation</h1>
<p>To renew your membership, please enter your details below:</p> <p>To activate a new membership, please enter your details below:</p>
<form class="ui form" method="POST" action="{{ url('renew_search') }}"> <form class="ui form" method="POST" action="{{ url('monboard_search') }}">
<div class="ui required inline grid field"> <div class="ui required inline grid field">
<label class="three wide column">Student ID</label> <label class="three wide column">Student ID</label>
<div class="nine wide column"> <div class="nine wide column">
@ -36,7 +36,7 @@
<label class="three wide column">Email</label> <label class="three wide column">Email</label>
<div class="nine wide column"> <div class="nine wide column">
<input type="text" name="email" placeholder="abcd0001@student.monash.edu"> <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 renewal notice.</div> <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> </div>
</div> </div>
<div class="ui divider"></div> <div class="ui divider"></div>

View File

@ -0,0 +1,122 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Membership activation{% endblock %}
{% block content %}
<h1>Membership activation</h1>
{% if not member %}
<p>The details you entered do not match our records, or the membership has already been activated. <a href="{{ url('monboard_index') }}">Click here</a> to try again.</p>
{% else %}
<p>Please check the following details and update them if necessary:</p>
<form class="ui form" method="POST" action="{{ url('monboard_save') }}">
{% if errors %}
<div class="ui visible error message"><ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul></div>
{% endif %}
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>
<input class="nine wide column" type="text" name="student_id" value="{{ member.student_id }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Student email</label>
<input class="nine wide column" type="text" name="email" value="{{ member.email }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">First name</label>
<input class="nine wide column" type="text" name="first_name" value="{{ member.first_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Last name</label>
<input class="nine wide column" type="text" name="last_name" value="{{ member.last_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Phone number</label>
<input class="nine wide column" type="text" name="phone" value="{{ member.phone }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Year level</label>
<select id="drop_year" class="ui dropdown eleven wide column" name="year">
<option value="">Year level</option>
{% for year in years %}
<option value="{{ year[0] }}">{{ year[1] }}</option>
{% endfor %}
</select>
</div>
<div class="ui required inline grid field">
<label class="three wide column">MSA membership</label>
<select id="drop_msa" class="ui dropdown eleven wide column" name="is_msa">
<option value="">MSA membership</option>
<option value="0">No, I am not an MSA member</option>
<option value="1">Yes, I am an MSA member</option>
</select>
</div>
<div class="ui divider"></div>
<h2>MUMUS Mail</h2>
<div class="ui required inline grid field">
<label class="three wide column">Opt-in/out</label>
<select id="drop_bulletin_subscribe" class="ui dropdown eleven wide column" name="bulletin_subscribe">
<option value="0">Do not send me MUMUS Mail (not recommended)</option>
<option value="1" selected>Keep me updated with MUMUS Mail</option>
</select>
</div>
<div class="ui inline grid field">
<label class="three wide column">Subscriptions</label>
<div class="eleven wide column">
{% for group in import('sspromotions.models').Group.objects.all() %}
{% if group.subscribable %}
<div class="field" style="display: inline; margin-right: 1em;">
<div class="ui checkbox">
<input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}">
<label for="bulletin_group_{{ group.id }}">{{ group.name }}</label>
</div>
</div>
{% endif %}
{% endfor %}
<p style="margin-top: 0.5em;">MUMUS Mail is personalised for you. Choose the groups that you would like to see first in each edition of MUMUS Mail.</p>
</div>
</div>
<div class="ui divider"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input type="hidden" name="email_orig" value="{{ email_orig }}">
<input type="hidden" name="sig" value="{{ sig }}">
<input class="ui primary button" type="submit" name='submit' value="Continue">
</form>
{% endif %}
{% endblock %}
{% block script %}
{{ super() }}
{% if member %}
<script>
$('.ui.dropdown').dropdown();
$('#drop_year').dropdown('set selected', '{{ member.year }}');
$('#drop_msa').dropdown('set selected', '{{ 1 if member.is_msa else 0 }}');
</script>
{% endif %}
{% endblock %}

View File

@ -1,116 +0,0 @@
{% extends 'ssmain/base.html' %}
{#
Society Self-Service
Copyright © 2018-2022 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Subscribe to {{ settings.ORG_NAME }} Mail{% endblock %}
{% block content %}
<h1>Subscribe to {{ settings.ORG_NAME }} Mail</h1>
<p>To become an associate member of {{ settings.ORG_NAME }} and subscribe to {{ settings.ORG_NAME }} Mail, please enter your details below:</p>
<form class="ui form" method="POST" action="{{ url('signup_save') }}">
{% if errors %}
<div class="ui visible error message"><ul>
{% for error in errors %}
<li>{{ error }}</li>
{% endfor %}
</ul></div>
{% endif %}
<div class="ui required inline grid field">
<label class="three wide column">Student ID</label>
<input class="nine wide column" type="text" name="student_id" value="{{ member.student_id }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Student email</label>
<input class="nine wide column" type="text" name="email" value="{{ member.email }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">First name</label>
<input class="nine wide column" type="text" name="first_name" value="{{ member.first_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Last name</label>
<input class="nine wide column" type="text" name="last_name" value="{{ member.last_name }}">
</div>
<div class="ui required inline grid field">
<label class="three wide column">Phone number</label>
<input class="nine wide column" type="text" name="phone" value="{{ member.phone }}">
</div>
<div class="ui divider"></div>
<div class="ui required inline grid field">
<label class="three wide column">Year level</label>
<select id="drop_year" class="ui dropdown eleven wide column" name="year">
<option value="">Year level</option>
{% for year in years %}
<option value="{{ year[0] }}">{{ year[1] }}</option>
{% endfor %}
</select>
</div>
<div class="ui required inline grid field">
<label class="three wide column">MSA membership</label>
<select id="drop_msa" class="ui dropdown eleven wide column" name="is_msa">
<option value="">MSA membership</option>
<option value="0">No, I am not an MSA member</option>
<option value="1">Yes, I am an MSA member</option>
</select>
</div>
{% if 'sspromotions' in settings.INSTALLED_APPS %}
<div class="ui divider"></div>
<h2>MUMUS Mail</h2>
<div class="ui required inline grid field">
<label class="three wide column">Opt-in/out</label>
<select id="drop_bulletin_subscribe" class="ui dropdown eleven wide column" name="bulletin_subscribe">
<option value="0">Do not send me MUMUS Mail (not recommended)</option>
<option value="1" selected>Keep me updated with MUMUS Mail</option>
</select>
</div>
<div class="ui inline grid field">
<label class="three wide column">Subscriptions</label>
<div class="eleven wide column">
{% for group in import('sspromotions.models').Group.objects.all() %}
{% if group.subscribable %}
<div class="field" style="display: inline; margin-right: 1em;">
<div class="ui checkbox">
<input type="checkbox" name="bulletin_group_{{ group.id }}" id="bulletin_group_{{ group.id }}">
<label for="bulletin_group_{{ group.id }}">{{ group.name }}</label>
</div>
</div>
{% endif %}
{% endfor %}
<p style="margin-top: 0.5em;">MUMUS Mail is personalised for you. Choose the groups that you would like to see first in each edition of MUMUS Mail.</p>
</div>
</div>
{% endif %}
<div class="ui divider"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Subscribe">
</form>
{% endblock %}
{% block script %}
{{ super() }}
<script>
$('.ui.dropdown').dropdown();
$('#drop_year').dropdown('set selected', '{{ member.year }}');
$('#drop_msa').dropdown('set selected', '{{ 1 if member.is_msa else 0 }}');
</script>
{% endblock %}

View File

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

View File

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

View File

@ -0,0 +1,107 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import boto3
from botocore.exceptions import ClientError
import premailer
from django.core.management.base import BaseCommand, CommandError
from django.conf import settings
from django.template import loader
from django.urls import reverse
from ssmembership.mimport import get_members
import hmac
import logging
import time
import urllib.parse
class Command(BaseCommand):
help = 'Send emails for membership import (renewal)'
def add_arguments(self, parser):
parser.add_argument('ids', nargs='*', type=int, help='Members with ID numbers equal to these values will be emailed (default all)')
parser.add_argument('--render', action='store_true', help='Render to stdout instead of sending emails')
def handle(self, *args, **options):
template_html = loader.get_template('ssmembership/email/import.html')
template_txt = loader.get_template('ssmembership/email/import.txt')
members = get_members()
if len(options['ids']) > 0:
members = [member for member in members if member[0] in options['ids']]
else:
raise Exception('Must provide IDs')
client = boto3.client('ses', aws_access_key_id=settings.AWS_KEY_ID, aws_secret_access_key=settings.AWS_SECRET, region_name=settings.AWS_REGION)
def send_mail(**kwargs):
for i in range(0, 10):
try:
client.send_email(**kwargs)
return
except ClientError as e:
if e['Error']['Code'] == 'Throttling' and e['Error']['Message'] == 'Maximum sending rate exceeded.':
wait_time = max(10*(2**i), 5000)
self.stdout.write(self.style.NOTICE('Reached maximum sending rate, waiting {} ms'.format(wait_time)))
time.sleep(wait_time/1000)
else:
raise e
raise Exception('Reached maximum number of retries')
for member in members:
#_id, student_id, email, first_name, last_name, year, is_msa, phone, date
sig = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member[2].encode('utf-8'), 'sha256').hexdigest()
renew_url = reverse('mimport_signed') + '?' + urllib.parse.urlencode({'email': member[2], 'sig': sig})
template_args = {
'name': member[3].strip() + ' ' + member[4].strip(),
'renew_url': renew_url,
'baseurl': 'https://' + settings.ALLOWED_HOSTS[0]
}
content_html = premailer.Premailer(template_html.render(template_args), cssutils_logging_level=logging.ERROR).transform()
content_txt = template_txt.render(template_args)
if options['render']:
self.stdout.write(content_html)
else:
self.stdout.write('Emailing {} at {}'.format(member[0], member[2]))
send_mail(
Destination={
'ToAddresses': [member[2]],
},
Message={
'Body': {
'Html': {
'Charset': 'utf-8',
'Data': content_html,
},
'Text': {
'Charset': 'utf-8',
'Data': content_txt,
},
},
'Subject': {
'Charset': 'utf-8',
'Data': settings.ORG_NAME + ' membership renewal',
},
},
Source='{} <{}>'.format(settings.ORG_NAME, settings.AWS_SENDER_EMAIL),
)

View File

@ -1,47 +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/>.
from django.core.management.base import BaseCommand, CommandError
from django.utils import timezone
from ssmain.email import Emailer
import ssmembership.models
class Command(BaseCommand):
help = 'Send Markdown emails'
def add_arguments(self, parser):
parser.add_argument('template', help='Template name')
parser.add_argument('subject', help='Email subject')
parser.add_argument('ids', nargs='*', type=int, help='Members with ID numbers equal to these values will be emailed (default all)')
def handle(self, *args, **options):
today = timezone.localtime(timezone.now()).date()
members = ssmembership.models.Member.objects.all()
if len(options['ids']) > 0:
members = [member for member in members if member.id in options['ids']]
else:
raise Exception('Must provide IDs')
emailer = Emailer()
for member in members:
if member.member_type != 1 or member.expires < today:
self.stdout.write('Skipping {} at {}'.format(member.id, member.email))
continue
self.stdout.write('Emailing {} at {}'.format(member.id, member.email))
emailer.send_mail([member.email], options['subject'], 'ssmembership/email/' + options['template'] + '.md', {})

View File

@ -0,0 +1,112 @@
# 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])

70
ssmembership/mimport.py Normal file
View File

@ -0,0 +1,70 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import sqlite3
from django.utils import timezone
from . import models
import datetime
def get_members():
conn = sqlite3.connect('file:members.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members')
result = cur.fetchall()
conn.close()
return result
def by_email(email):
conn = sqlite3.connect('file:members.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members WHERE email=?', (email,))
result = cur.fetchone()
conn.close()
if not result:
return None
member = models.Member()
# id, student_id, email, first_name, last_name, year, is_msa, phone, date
member.student_id = result[1]
member.email = result[2]
member.first_name = result[3]
member.last_name = result[4]
member.year = {'Year A': 0, 'Year 1': 1, 'Year 2': 2, 'Year 3B': 3, 'Year 4C': 4, 'Year 5D': 5, 'BMedSci': 97, 'PhD': 98, 'Intermission': 99}[result[5]]
member.is_msa = result[6]
member.phone = result[7]
# Calculate expiration date
member.expires = timezone.now().date().replace(month=3, day=31)
member.expires = member.expires.replace(year=member.expires.year+1)
if member.expires < timezone.now().date(): # Add 1 year if after Mar 31, else add 2 years
member.expires = member.expires.replace(year=member.expires.year+1)
return member
def delete_by_email(email):
conn = sqlite3.connect('file:members.db', uri=True)
cur = conn.cursor()
cur.execute('DELETE FROM members WHERE email=?', (email,))
conn.commit()
conn.close()

View File

@ -42,13 +42,6 @@ class Member(models.Model):
phone = models.CharField(max_length=20) 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() expires = models.DateField()
class Meta: class Meta:

77
ssmembership/monboard.py Normal file
View File

@ -0,0 +1,77 @@
# Society Self-Service
# Copyright © 2018-2019 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import sqlite3
from django.utils import timezone
from . import models
import datetime
def get_members():
conn = sqlite3.connect('file:onboards.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members')
result = cur.fetchall()
conn.close()
return result
def by_email(email):
conn = sqlite3.connect('file:onboards.db?mode=ro', uri=True)
cur = conn.cursor()
cur.execute('SELECT * FROM members WHERE email=? AND imported=0', (email,))
result = cur.fetchone()
conn.close()
if not result:
return None
member = models.Member()
# id, student_id, email, first_name, last_name, year, is_msa, phone, date, purchased, imported
member.student_id = result[1]
member.email = result[2]
member.first_name = result[3]
member.last_name = result[4]
member.year = {'Year A': 0, 'Year 1': 1, 'Year 2': 2, 'Year 3B': 3, 'Year 4C': 4, 'Year 5D': 5, 'BMedSci': 97, 'PhD': 98, 'Intermission': 99}[result[5]]
member.is_msa = result[6]
member.phone = result[7]
# Calculate expiration date
member.expires = timezone.now().date().replace(month=3, day=31)
member.expires = member.expires.replace(year=member.expires.year+1)
if member.expires < timezone.now().date(): # Add 1 year if after Mar 31, else add 2 years
member.expires = member.expires.replace(year=member.expires.year+1)
return member
def delete_by_email(email):
conn = sqlite3.connect('file:onboards.db', uri=True)
cur = conn.cursor()
cur.execute('UPDATE members SET imported=1 WHERE email=?', (email,))
conn.commit()
conn.close()
def set_emailed_by_email(email):
conn = sqlite3.connect('file:onboards.db', uri=True)
cur = conn.cursor()
cur.execute('UPDATE members SET emailed=1 WHERE email=?', (email,))
conn.commit()
conn.close()

View File

@ -20,10 +20,12 @@ from . import views
urlpatterns = [ urlpatterns = [
path('', views.index, name='membership'), path('', views.index, name='membership'),
path('renew/', views.renew_index, name='renew_index'), path('import/', views.import_index, name='mimport_index'),
path('renew/signed', views.renew_signed, name='renew_signed'), path('import/signed', views.import_signed, name='mimport_signed'),
path('renew/search', views.renew_search, name='renew_search'), path('import/search', views.import_search, name='mimport_search'),
path('renew/save', views.renew_save, name='renew_save'), path('import/save', views.import_save, name='mimport_save'),
path('signup/', views.signup_index, name='signup_index'), path('onboard/', views.onboard_index, name='monboard_index'),
path('signup/save', views.signup_save, name='signup_save'), 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'),
] ]

View File

@ -1,5 +1,5 @@
# Society Self-Service # Society Self-Service
# Copyright © 2018-2022 Yingtong Li (RunasSudo) # Copyright © 2018-2019 Yingtong Li (RunasSudo)
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -23,8 +23,9 @@ from django.db import transaction
from django.http import HttpResponse from django.http import HttpResponse
from django.shortcuts import render, redirect from django.shortcuts import render, redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone
from . import mimport
from . import monboard
from . import models from . import models
import hmac import hmac
@ -70,10 +71,10 @@ def index(request):
return render(request, 'ssmembership/index.html', {'member': member, 'years': models.Member.YEARS}) return render(request, 'ssmembership/index.html', {'member': member, 'years': models.Member.YEARS})
def renew_index(request): def import_index(request):
return render(request, 'ssmembership/renew/index.html') return render(request, 'ssmembership/import/index.html')
def renew_signed(request): def import_signed(request):
if 'email' not in request.GET: if 'email' not in request.GET:
return HttpResponse('Expected an email address', status=400) return HttpResponse('Expected an email address', status=400)
if 'sig' not in request.GET: if 'sig' not in request.GET:
@ -83,8 +84,8 @@ def renew_signed(request):
if not hmac.compare_digest(sig_expected, request.GET['sig']): if not hmac.compare_digest(sig_expected, request.GET['sig']):
return HttpResponse('Invalid signature', status=403) return HttpResponse('Invalid signature', status=403)
member = models.Member.objects.get(email=request.GET['email']) member = mimport.by_email(request.GET['email'])
return render(request, 'ssmembership/renew/review.html', { return render(request, 'ssmembership/import/review.html', {
'member': member, 'member': member,
'years': models.Member.YEARS, 'years': models.Member.YEARS,
'email_orig': member.email if member else None, 'email_orig': member.email if member else None,
@ -92,60 +93,54 @@ def renew_signed(request):
}) })
@ratelimit(key=settings.RATELIMIT_KEY, rate='100/h') @ratelimit(key=settings.RATELIMIT_KEY, rate='100/h')
def renew_search(request): def import_search(request):
if request.method != 'POST': if request.method != 'POST':
return redirect(reverse('renew_index')) return redirect(reverse('import_index'))
if request.limited: if request.limited:
return HttpResponse('Too many requests', status=429) return HttpResponse('Too many requests', status=429)
try: member = mimport.by_email(request.POST['email'])
member = models.Member.objects.get(email=request.POST['email']) if member and member.student_id != request.POST['student_id']:
if member.student_id != request.POST['student_id']:
member = None
except models.Member.DoesNotExist:
member = None member = None
return render(request, 'ssmembership/renew/review.html', { return render(request, 'ssmembership/import/review.html', {
'member': member, 'member': member,
'years': models.Member.YEARS, 'years': models.Member.YEARS,
'email_orig': member.email if member else None, '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 'sig': hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member.email.encode('utf-8'), 'sha256').hexdigest() if member else None
}) })
def renew_save(request): def import_save(request):
if request.method != 'POST': if request.method != 'POST':
return redirect(reverse('renew_index')) return redirect(reverse('import_index'))
sig_expected = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), request.POST['email_orig'].encode('utf-8'), 'sha256').hexdigest() 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']): if not hmac.compare_digest(sig_expected, request.POST['sig']):
return HttpResponse('Invalid signature', status=403) return HttpResponse('Invalid signature', status=403)
member = models.Member.objects.get(email=request.POST['email_orig']) member = mimport.by_email(request.POST['email_orig'])
member.student_id = request.POST['student_id'].strip() if not member:
if not request.POST['email_orig'].endswith('@student.monash.edu'): return render(request, 'ssmembership/import/review.html', {
member.email = request.POST['email'].strip() 'member': member
member.first_name = request.POST['first_name'].strip() })
member.last_name = request.POST['last_name'].strip()
member.phone = request.POST['phone'].strip() 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.year = int(request.POST['year']) member.year = int(request.POST['year'])
member.is_msa = True if request.POST['is_msa'] == '1' else '0' 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() errors = member.validation_problems()
if not request.POST['email_orig'].endswith('@student.monash.edu') and models.Member.objects.filter(email=member.email).count() > 0: if models.Member.objects.filter(email=request.POST['email']).count() > 0:
errors.append('Member with this email already exists') errors.append('Member with this email already exists')
if len(errors) > 0: if len(errors) > 0:
return render(request, 'ssmembership/renew/review.html', { return render(request, 'ssmembership/import/review.html', {
'member': member, 'member': member,
'years': models.Member.YEARS, 'years': models.Member.YEARS,
'email_orig': request.POST['email_orig'], 'email_orig': request.POST['email_orig'],
@ -157,7 +152,6 @@ def renew_save(request):
member.save() member.save()
# Update bulletin # Update bulletin
if 'sspromotions' in settings.INSTALLED_APPS:
import sspromotions.models import sspromotions.models
sspromotions.models.BulletinSubscription.set_member_subscribed(member, True if request.POST['bulletin_subscribe'] == '1' else False) 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(): for group in sspromotions.models.Group.objects.filter(subscribable=True).all():
@ -166,19 +160,64 @@ def renew_save(request):
else: else:
group.subscribe_member(member, False) group.subscribe_member(member, False)
return render(request, 'ssmembership/renew/complete.html') mimport.delete_by_email(request.POST['email_orig'])
return render(request, 'ssmembership/import/complete.html')
def signup_index(request): def onboard_index(request):
return render(request, 'ssmembership/signup/index.html', { return render(request, 'ssmembership/onboard/index.html')
'member': models.Member(),
'years': models.Member.YEARS 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_save(request): @ratelimit(key=settings.RATELIMIT_KEY, rate='100/h')
def onboard_search(request):
if request.method != 'POST': if request.method != 'POST':
return redirect(reverse('signup_index')) 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
})
member = models.Member()
member.student_id = request.POST['student_id'] member.student_id = request.POST['student_id']
member.email = request.POST['email'] member.email = request.POST['email']
member.first_name = request.POST['first_name'] member.first_name = request.POST['first_name']
@ -186,13 +225,6 @@ def signup_save(request):
member.phone = request.POST['phone'] member.phone = request.POST['phone']
member.year = int(request.POST['year']) member.year = int(request.POST['year'])
member.is_msa = True if request.POST['is_msa'] == '1' else '0' 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() errors = member.validation_problems()
@ -200,9 +232,11 @@ def signup_save(request):
errors.append('Member with this email already exists') errors.append('Member with this email already exists')
if len(errors) > 0: if len(errors) > 0:
return render(request, 'ssmembership/signup/index.html', { return render(request, 'ssmembership/onboard/review.html', {
'member': member, 'member': member,
'years': models.Member.YEARS, 'years': models.Member.YEARS,
'email_orig': request.POST['email_orig'],
'sig': request.POST['sig'],
'errors': errors 'errors': errors
}) })
@ -210,7 +244,6 @@ def signup_save(request):
member.save() member.save()
# Update bulletin # Update bulletin
if 'sspromotions' in settings.INSTALLED_APPS:
import sspromotions.models import sspromotions.models
sspromotions.models.BulletinSubscription.set_member_subscribed(member, True if request.POST['bulletin_subscribe'] == '1' else False) 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(): for group in sspromotions.models.Group.objects.filter(subscribable=True).all():
@ -219,4 +252,5 @@ def signup_save(request):
else: else:
group.subscribe_member(member, False) group.subscribe_member(member, False)
return render(request, 'ssmembership/signup/complete.html') monboard.delete_by_email(request.POST['email_orig'])
return render(request, 'ssmembership/onboard/complete.html')

View File

@ -73,12 +73,7 @@
</div> </div>
<div class="ui inline grid field"> <div class="ui inline grid field">
<label class="three wide column">Image</label> <label class="three wide column">Image</label>
<div class="eleven wide column"> <input class="eleven wide column" type="file" name="image" value="{{ item.image or '' }}">
{% 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>
<div class="ui divider"></div> <div class="ui divider"></div>
<div class="ui inline grid field"> <div class="ui inline grid field">
@ -100,9 +95,6 @@
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}"> <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<input class="ui primary button" type="submit" name='submit' value="Save"> <input class="ui primary button" type="submit" name='submit' value="Save">
<input class="ui button" type="submit" name='submit' value="Save and continue editing"> <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> </form>
{% endblock %} {% endblock %}

View File

@ -2,7 +2,7 @@
{# {#
Society Self-Service Society Self-Service
Copyright © 2018-2019 Yingtong Li (RunasSudo) Copyright © 2018 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU Affero General Public License as published by
@ -24,9 +24,9 @@
<table class="ui selectable celled table"> <table class="ui selectable celled table">
<thead> <thead>
<tr> <tr>
<th class="five wide">Title</th> <th class="four wide">Title</th>
<th class="ten wide">Content</th> <th class="ten wide">Content</th>
<th class="one wide">Edit</th> <th class="two wide">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@ -34,8 +34,9 @@
<tr> <tr>
<td class="selectable"><a href="{{ url('bulletin_edit', kwargs={'id': item.id}) }}">{{ item.title }}</a></td> <td class="selectable"><a href="{{ url('bulletin_edit', kwargs={'id': item.id}) }}">{{ item.title }}</a></td>
<td>{{ item.content|markdown }}</td> <td>{{ item.content|markdown }}</td>
<td> <td class="selectable">
<a href="{{ url('bulletin_edit', kwargs={'id': item.id}) }}" class="ui tiny primary icon button"><i class="edit icon"></i></a> <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> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@ -46,7 +47,7 @@
{% block maincontent %} {% block maincontent %}
<h1>Your bulletin items</h1> <h1>Your bulletin items</h1>
{% if not items_past_page and not items_upcoming and not items_future %} {% if not items_past 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> <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 %} {% endif %}
@ -62,22 +63,10 @@
{{ listitems(items_future) }} {{ listitems(items_future) }}
{% endif %} {% endif %}
{% if items_past_page %} {% if items_past %}
<h2>Past bulletin items</h2> <h2>Past bulletin items</h2>
{{ listitems(items_past_page.object_list) }} {{ listitems(items_past) }}
<div style="text-align: center;">
<div class="ui pagination menu">
{% if items_past_page.has_previous() %}
<a class="item" href="?page={{ items_past_page.previous_page_number() }}">&lsaquo; Prev</a>
{% endif %}
<a class="active item">Page {{ items_past_page.number }} of {{ items_past_page.paginator.num_pages }}</a>
{% if items_past_page.has_next() %}
<a class="item" href="?page={{ items_past_page.next_page_number() }}">Next &rsaquo;</a>
{% endif %}
</div>
</div>
{% endif %} {% endif %}
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -33,8 +33,6 @@ import logging
import time import time
import urllib.parse import urllib.parse
bulldt = timezone.localtime(timezone.now())
def send_aws_email(client, email, subject, content_html, content_txt): def send_aws_email(client, email, subject, content_html, content_txt):
def send_mail(**kwargs): def send_mail(**kwargs):
for i in range(0, 10): for i in range(0, 10):
@ -93,19 +91,15 @@ 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) 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, bulldt.strftime('%d %B %Y')) title = '{} News: {}'.format(settings.ORG_NAME, timezone.now().strftime('%d %B %Y'))
calbegin, calend, bulbegin, bulend = sspromotions.utils.bulletin_dates(bulldt) calbegin, calend, bulbegin, bulend = sspromotions.utils.bulletin_dates(timezone.now())
events = list(sspromotions.utils.get_calendar_events(calbegin, calend)) events = list(sspromotions.utils.get_calendar_events(calbegin, calend))
for member in members: for member in members:
if sspromotions.models.BulletinSubscription.is_member_subscribed(member): 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) 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_html = premailer.Premailer(template_html.render(template_args), cssutils_logging_level=logging.ERROR, strip_important=False).transform()
content_txt = template_txt.render(template_args) content_txt = template_txt.render(template_args)

View File

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

View File

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

View File

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

View File

@ -1,97 +0,0 @@
# Society Self-Service
# Copyright © 2018–2020 Yingtong Li (RunasSudo)
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU Affero General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU Affero General Public License for more details.
#
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
# https://www.brad-smith.info/blog/archives/405
# https://www.cemtexaba.com/aba-format/cemtex-aba-file-format-details
# https://ddkonline.blogspot.com/2009/01/aba-bank-payment-file-format-australian.html
class ABAException(Exception):
pass
def write_descriptive(f, reel_seq=1, bank_name='', user_name='', bank_code=0, description='', date=None):
if reel_seq < 0 or reel_seq > 99:
raise ABAException('Invalid Reel Sequence Number: {}'.format(reel_seq))
if len(bank_name) > 3:
raise ABAException('Invalid Financial Institution abbreviation: {}'.format(bank_name))
if len(user_name) > 26:
raise ABAException('Invalid User Preferred Specification: {}'.format(user_name))
if bank_code < 0 or bank_code > 999999:
raise ABAException('Invalid User Identification Number: {}'.format(bank_code))
if len(description) > 12:
raise ABAException('Invalid Description: {}'.format(description))
f.write(b'0') # Record Type 0
f.write(b' ' * 17) # Blank
f.write('{:02}'.format(reel_seq).encode('ascii')) # Reel Sequence Number
f.write('{: <3}'.format(bank_name).encode('ascii')) # Financial Institution abbreviation
f.write(b' ' * 7) # Blank
f.write('{: <26}'.format(user_name).encode('ascii')) # User Preferred Specification
f.write('{:06}'.format(bank_code).encode('ascii')) # User Identification Number
f.write('{: <12}'.format(description).encode('ascii')) # Description
f.write(date.strftime('%d%m%y').encode('ascii')) # Date
f.write(b' ' * 40) # Blank
f.write(b'\r\n')
def write_detail(f, dest_bsb='', dest_account='', indicator=' ', transaction_code=53, cents=0, dest_name='', reference='', src_bsb='', src_account='', src_name='', tax_withheld=0):
dest_bsb = dest_bsb.replace('-', '').replace(' ', '')
dest_account = dest_account.replace('-', '').replace(' ', '')
src_bsb = src_bsb.replace('-', '').replace(' ', '')
src_account = src_account.replace('-', '').replace(' ', '')
if len(dest_bsb) != 6:
raise ABAException('Invalid BSB: {}'.format(dest_bsb))
if len(dest_account) > 9:
raise ABAException('Invalid Account Number: {}'.format(dest_account))
if len(indicator) != 1:
raise ABAException('Invalid Indicator: {}'.format(indicator))
if transaction_code < 0 or transaction_code > 99:
raise ABAException('Invalid Transaction Code: {}'.format(indicator))
if len(dest_name) > 32:
raise ABAException('Invalid Title of Account: {}'.format(dest_name))
if len(reference) > 18:
raise ABAException('Invalid Lodgement Reference: {}'.format(reference))
if len(src_bsb) != 6:
raise ABAException('Invalid Trace BSB: {}'.format(src_bsb))
if len(src_account) > 9:
raise ABAException('Invalid Trace Account Number: {}'.format(src_account))
if len(src_name) > 16:
raise ABAException('Invalid Name of Remitter: {}'.format(src_name))
f.write(b'1') # Record Type 1
f.write('{}-{}'.format(dest_bsb[:3], dest_bsb[-3:]).encode('ascii')) # BSB
f.write('{: >9}'.format(dest_account).encode('ascii')) # Account Number
f.write(indicator.encode('ascii')) # Indicator
f.write('{:02}'.format(transaction_code).encode('ascii')) # Transaction Code
f.write('{:010}'.format(round(cents)).encode('ascii')) # Amount
f.write('{: <32}'.format(dest_name).encode('ascii')) # Title of Account
f.write('{: <18}'.format(reference).encode('ascii')) # Lodgement Reference
f.write('{}-{}'.format(src_bsb[:3], src_bsb[-3:]).encode('ascii')) # Trace BSB
f.write('{: >9}'.format(src_account).encode('ascii')) # Trace Account Number
f.write('{: <16}'.format(src_name).encode('ascii')) # Name of Remitter
f.write('{:08}'.format(round(tax_withheld)).encode('ascii')) # Amount of Withholding Tax
f.write(b'\r\n')
def write_total(f, credit_cents=0, num_detail_records=0):
f.write(b'7') # Record Type 7
f.write(b'999-999') # BSB Format Filler
f.write(b' ' * 12) # Blank
f.write('{:010}'.format(round(credit_cents)).encode('ascii')) # File (User) Net Total Amount
f.write('{:010}'.format(round(credit_cents)).encode('ascii')) # File (User) Credit Total Amount
f.write(b'0' * 10) # File (User) Debit Total Amount
f.write(b' ' * 24) # Blank
f.write('{:06}'.format(num_detail_records).encode('ascii')) # File (user) count of Records Type 1
f.write(b' ' * 40) # Blank
f.write(b'\r\n')

View File

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

View File

@ -2,8 +2,7 @@
{# {#
Society Self-Service Society Self-Service
Copyright © 2018-2023 Yingtong Li (RunasSudo) Copyright © 2018 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU Affero General Public License as published by
@ -25,28 +24,16 @@
<h1>{% if request.resolver_match.url_name == 'budget_new' %}New{% else %}Edit{% endif %} budget</h1> <h1>{% if request.resolver_match.url_name == 'budget_new' %}New{% else %}Edit{% endif %} budget</h1>
<form class="ui form" method="POST"> <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"> <div class="ui disabled inline grid field">
<label class="three wide column">ID</label> <label class="three wide column">ID</label>
<input class="eleven wide column" type="text" name="id" value="{{ 'BU-{}'.format(revision.budget.id) if revision.budget.id != None else '' }}"> <input class="eleven wide column" type="text" name="id" value="{{ revision.budget.id if revision.budget.id != None else '' }}">
</div> </div>
<div class="ui required inline grid field"> <div class="ui required inline grid field">
<label class="three wide column">Title</label> <label class="three wide column">Name</label>
<input class="eleven wide column" type="text" name="name" value="{{ revision.name }}"> <input class="eleven wide column" type="text" name="name" value="{{ revision.name }}">
</div> </div>
<div class="ui required inline grid field"> <div class="ui 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> <label class="three wide column">Due date</label>
<div class="eleven wide column"> <div class="eleven wide column">
<div class="ui calendar" id="cal_date"> <div class="ui calendar" id="cal_date">
<div class="ui input left icon grid"> <div class="ui input left icon grid">
@ -56,49 +43,10 @@
</div> </div>
</div> </div>
</div> </div>
<div class="ui inline grid field"> <div class="ui required 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> <label class="three wide column">Contributors</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> <textarea class="eleven wide column" rows="2" name="contributors" style="font-family: Lato,'Helvetica Neue',Arial,Helvetica,sans-serif;">{{ contributors }}</textarea>
</div> </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 divider"></div>
<div class="ui inline grid field"> <div class="ui inline grid field">
<label class="three wide column">Comments</label> <label class="three wide column">Comments</label>
@ -107,14 +55,13 @@
<div class="ui divider"></div> <div class="ui divider"></div>
<div class="ui inline grid field"> <div class="ui inline grid field">
<label class="three wide column">Revenue</label> <label class="three wide column">Revenue</label>
<div class="eleven wide column"></div>
</div>
<div id="revenue_grid"></div> <div id="revenue_grid"></div>
<input type="hidden" name="revenue" id="revenue_input"> <input type="hidden" name="revenue" id="revenue_input">
</div>
<div class="ui accordion"> <div class="ui accordion">
<div class="{% if revision.revenue_comments %}active {% endif %}title"> <div class="{% if revision.revenue_comments %}active {% endif %}title">
<i class="dropdown icon"></i> <i class="dropdown icon"></i>
Revenue comments (click to show/hide) Revenue comments
</div> </div>
<div class="content"> <div class="content">
<div class="ui inline grid field"> <div class="ui inline grid field">
@ -126,17 +73,16 @@
<div class="ui divider"></div> <div class="ui divider"></div>
<div class="ui inline grid field"> <div class="ui inline grid field">
<label class="three wide column">Expenses</label> <label class="three wide column">Expenses</label>
<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 id="expense_grid"></div> <div id="expense_grid"></div>
<input type="hidden" name="expense" id="expense_input"> <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 (click to show/hide)
</div> </div>
<div class="{% if revision.expense_comments or revision.expense_no_emergency_fund %}active {% endif %}content"> <div class="ui accordion">
<div class="ui {% if revision.expense_no_emergency_fund %}required {% endif %}inline grid field"> <div class="{% if revision.expense_comments %}active {% endif %}title">
<i class="dropdown icon"></i>
Expense comments
</div>
<div class="{% if revision.expense_comments %}active {% endif %}content">
<div class="ui inline grid field">
<label class="three wide column">Comments</label> <label class="three wide column">Comments</label>
<textarea class="eleven wide column" rows="2" name="expense_comments">{{ revision.expense_comments }}</textarea> <textarea class="eleven wide column" rows="2" name="expense_comments">{{ revision.expense_comments }}</textarea>
</div> </div>
@ -145,11 +91,8 @@
<div class="ui divider"></div> <div class="ui divider"></div>
<div class="ui error message"></div> <div class="ui error message"></div>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}"> <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 primary button" type="submit" name='submit' value="Save">
<input class="ui button" type="submit" name='submit' value="Save and continue editing"> <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> </form>
{% endblock %} {% endblock %}
@ -160,14 +103,6 @@
<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.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/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"> <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 %} {% endblock %}
{% block script %} {% block script %}
@ -177,14 +112,13 @@
<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/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="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> <script>
function leftpad(n) { function leftpad(n) {
if (n < 10) if (n < 10)
return '0' + n; return '0' + n;
return '' + n; return '' + n;
} }
$('#cal_date').calendar({ $('#cal_date').calendar({
type: 'date', type: 'date',
formatter: { formatter: {
@ -193,40 +127,23 @@
} }
} }
}); });
$('#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();
$('#expense_no_emergency_fund').change(function() { $('.ui.accordion').accordion();
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({ $('.ui.form').form({
on: 'blur', on: 'blur',
keyboardShortcuts: false, keyboardShortcuts: false,
fields: {
name: 'empty',
contributors: 'empty'
},
onSuccess: function(event, fields) { onSuccess: function(event, fields) {
var revenue_data = []; var revenue_data = [];
$('#revenue_grid .jsgrid-grid-body tr:not(.totalrow):not(.jsgrid-nodata-row)').each(function(i, el) { $('#revenue_grid .jsgrid-grid-body tr:not(.totalrow)').each(function(i, el) {
var row = $(el).data('JSGridItem'); var row = $(el).data('JSGridItem');
revenue_data.push({ revenue_data.push({
'Description': row['Description'], 'Description': row['Description'],
'Unit price': row['Unit price'], 'Unit cost': row['Unit cost'],
'Units': row['Units'], 'Units': row['Units'],
'IWT': row['IWT'], 'IWT': row['IWT'],
}); });
@ -234,11 +151,11 @@
$('#revenue_input').val(JSON.stringify(revenue_data)); $('#revenue_input').val(JSON.stringify(revenue_data));
var expense_data = []; var expense_data = [];
$('#expense_grid .jsgrid-grid-body tr:not(.totalrow):not(.jsgrid-nodata-row)').each(function(i, el) { $('#expense_grid .jsgrid-grid-body tr:not(.totalrow)').each(function(i, el) {
var row = $(el).data('JSGridItem'); var row = $(el).data('JSGridItem');
expense_data.push({ expense_data.push({
'Description': row['Description'], 'Description': row['Description'],
'Unit price': row['Unit price'], 'Unit cost': row['Unit cost'],
'Units': row['Units'], 'Units': row['Units'],
}); });
}); });
@ -247,7 +164,7 @@
}); });
// Interferes with jsGrid // Interferes with jsGrid
$('.ui.form').on('keyup keypress', ':input:not(textarea)', function(e) { $('.ui.form').on('keyup keypress', function(e) {
var keyCode = e.keyCode || e.which; var keyCode = e.keyCode || e.which;
if (keyCode === 13) { if (keyCode === 13) {
e.preventDefault(); e.preventDefault();
@ -255,14 +172,110 @@
} }
}); });
const ticketingFeeName = JSON.parse({{ import('json').dumps(import('json').dumps(settings.TICKETING_FEE_NAME))|safe }}); function recalcRevTotal(args) {
const ticketingFeeProportion = {{ settings.TICKETING_FEE_PROPORTION }}; //console.log(args);
const ticketingFeeFixed = {{ settings.TICKETING_FEE_FIXED }}; var total = 0;
const editing = true; 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;
var revenue_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.revenue))|safe }}); 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 }}); var expense_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.expense))|safe }});
makeGrid(); $('#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,
});
dragula([document.querySelector('#revenue_grid tbody')], { dragula([document.querySelector('#revenue_grid tbody')], {
accepts: function (el, target, source, sibling) { accepts: function (el, target, source, sibling) {
@ -280,7 +293,5 @@
return el.classList.contains('totalrow'); return el.classList.contains('totalrow');
} }
}); });
//$('.jsgrid-insert-mode-button').click();
</script> </script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

@ -2,8 +2,7 @@
{# {#
Society Self-Service Society Self-Service
Copyright © 2018–2023 Yingtong Li (RunasSudo) Copyright © 2018 Yingtong Li (RunasSudo)
Copyright © 2023 MUMUS Inc.
This program is free software: you can redistribute it and/or modify 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 it under the terms of the GNU Affero General Public License as published by
@ -21,79 +20,34 @@
{% block title %}{{ revision.name }}{% endblock %} {% block title %}{{ revision.name }}{% endblock %}
{% block aftersidebar %} {% block maincontent %}
<div class="eight wide column">
<h1>{{ revision.name }}</h1> <h1>{{ revision.name }}</h1>
<form class="ui form" action="{{ url('budget_action', kwargs={'id': revision.budget.id}) }}" method="POST">
<span class="ui header">Status: {{ revision.get_state_display() }}</span>
{% if is_latest %} {% if is_latest %}
{% if revision.can_submit(request.user) %} <div>
<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> <span class="ui header">Status: {{ import('sstreasury.models').BudgetState(revision.state).description }}</span>
{% 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> <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> </div>
{% endif %}
<input type="hidden" name="action" value="">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
{% else %} {% 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 warning message">
<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> <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> </div>
{% endif %} {% endif %}
</form>
<table class="ui mydefinition table"> <table class="ui mydefinition table">
<tbody> <tbody>
<tr> <tr>
<td class="two wide">ID</td> <td class="two wide">ID</td>
<td class="fourteen wide">BU-{{ revision.budget.id }}</td> <td class="fourteen wide">{{ revision.budget.id }}</td>
</tr> </tr>
<tr> <tr>
<td>Title</td> <td>Name</td>
<td>{{ revision.name }}</td> <td>{{ revision.name }}</td>
</tr> </tr>
<tr> <tr>
<td>Due date</td> <td>Due date</td>
<td>{{ revision.date or '' }}</td> <td>{{ revision.date or '' }}</td>
</tr> </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> <tr>
<td>Contributors</td> <td>Contributors</td>
<td> <td>
@ -113,17 +67,9 @@
</div> </div>
</td> </td>
</tr> </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> <tr>
<td>Comments</td> <td>Comments</td>
<td>{{ revision.comments|markdown }}</td> <td>{{ revision.comments }}</td>
</tr> </tr>
<tr> <tr>
<td>Revenue</td> <td>Revenue</td>
@ -136,7 +82,7 @@
Revenue comments Revenue comments
</div> </div>
<div class="active content"> <div class="active content">
{{ revision.revenue_comments|markdown }} {{ revision.revenue_comments }}
</div> </div>
</div> </div>
{% endif %} {% endif %}
@ -145,9 +91,6 @@
<tr> <tr>
<td>Expenses</td> <td>Expenses</td>
<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> <div id="expense_grid"></div>
{% if revision.expense_comments %} {% if revision.expense_comments %}
<div class="ui accordion"> <div class="ui accordion">
@ -156,16 +99,12 @@
Expense comments Expense comments
</div> </div>
<div class="active content"> <div class="active content">
{{ revision.expense_comments|markdown }} {{ revision.expense_comments }}
</div> </div>
</div> </div>
{% endif %} {% endif %}
</td> </td>
</tr> </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> </tbody>
</table> </table>
@ -174,19 +113,8 @@
<div class="required field"> <div class="required field">
<textarea rows="4" name="comment"></textarea> <textarea rows="4" name="comment"></textarea>
</div> </div>
<input type="hidden" name="action">
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}"> <input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
<button class="ui primary button" data-action="Comment" onclick="return uiSubmit(this);">Comment</button> <input class="ui primary button" type="submit" name="action" value="Comment">
{% 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> </form>
<div class="ui feed"> <div class="ui feed">
@ -205,7 +133,7 @@
</div> </div>
</div> </div>
<div class="extra text"> <div class="extra text">
{{ item.content|markdown }} {{ item.content }}
</div> </div>
</div> </div>
</div> </div>
@ -214,41 +142,10 @@
<div class="label"> <div class="label">
<i class="edit icon"></i> <i class="edit icon"></i>
</div> </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="content">
<div class="summary"> <div class="summary">
<i class="user circle icon"></i> <i class="user circle icon"></i>
<a href="mailto:{{ item.voter.email }}">{{ item.voter.first_name }} {{ item.voter.last_name }}</a> <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>
{% 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"> <div class="date">
{{ localtime(item.time) }} {{ localtime(item.time) }}
</div> </div>
@ -259,181 +156,6 @@
{% endfor %} {% endfor %}
</div> </div>
{% endif %} {% 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>
</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 %} {% endblock %}
{% block head %} {% block head %}
@ -460,57 +182,108 @@
.jsgrid-header-row .jsgrid-header-cell { .jsgrid-header-row .jsgrid-header-cell {
text-align: center !important; 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> </style>
{% endblock %} {% endblock %}
{% block script %} {% block script %}
{{ super() }} {{ 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/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> <script>
const ticketingFeeName = JSON.parse({{ import('json').dumps(import('json').dumps(settings.TICKETING_FEE_NAME))|safe }}); $('.ui.accordion').accordion();
const ticketingFeeProportion = {{ revision.ticketing_fee_proportion }};
const ticketingFeeFixed = {{ revision.ticketing_fee_fixed }}; function recalcRevTotal(args) {
const editing = false; //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;
var revenue_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.revenue))|safe }}); 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 }}); $('#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,
});
makeGrid(); var expense_data = JSON.parse({{ import('json').dumps(import('json').dumps(revision.expense))|safe }});
makeCharts(); $('#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,
});
</script> </script>
{% endblock %} {% endblock %}

View File

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

View File

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

View File

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

View File

@ -1,68 +0,0 @@
{% extends 'sstreasury/base.html' %}
{#
Society Self-Service
Copyright © 2018–2021 Yingtong Li (RunasSudo)
This program is free software: you can redistribute it and/or modify
it under the terms of the GNU Affero General Public License as published by
the Free Software Foundation, either version 3 of the License, or
(at your option) any later version.
This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
GNU Affero General Public License for more details.
You should have received a copy of the GNU Affero General Public License
along with this program. If not, see <https://www.gnu.org/licenses/>.
#}
{% block title %}Claims processing{% endblock %}
{% block maincontent %}
<h1>Claims processing</h1>
{% if error %}
<div class="ui error message">
An error occurred while generating the ABA file: {{ error }}
</div>
{% endif %}
<form class="ui form" method="POST">
<button class="ui small primary labeled icon button" type="submit" name="action" value="Export"><i class="download icon"></i>Export selected to ABA</button>
<button class="ui small basic primary labeled icon button" type="submit" name="action" value="ExportXero"><i class="download icon"></i>Export selected for Xero</button>
<button class="ui small basic primary labeled icon button" type="submit" name="action" value="Pay"><i class="check icon"></i>Mark selected as paid</button>
<table class="ui celled table">
<thead>
<tr>
<th class="one wide"><input type="checkbox" onchange="$(this.form).find('.claim-checkbox').prop('checked', this.checked);"></th>
<th class="nine wide">Purpose</th>
<th class="three wide">Payee</th>
<th class="two wide">Total</th>
<th class="one wide">View</th>
</tr>
</thead>
<tbody>
{% for claim in claims %}
<tr>
<td><input name="claim_{{ claim.id }}" type="checkbox" class="claim-checkbox"></td>
<td>{{ claim.purpose }}</td>
<td>{{ claim.payee_name }}</td>
<td>{{ '${:.2f}'.format(claim.get_total()) }}</td>
<td>
<a href="{{ url('claim_view', kwargs={'id': claim.id}) }}" class="ui tiny primary icon button"><i class="eye icon"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<input type="hidden" name="csrfmiddlewaretoken" value="{{ csrf_token }}">
</form>
{% endblock %}
{% block head %}
{{ super() }}
{% endblock %}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -22,16 +22,7 @@ urlpatterns = [
path('budgets/', views.budget_list, name='budget_list'), path('budgets/', views.budget_list, name='budget_list'),
path('budgets/new/', views.budget_new, name='budget_new'), 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>', 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/edit/<int:id>', views.budget_edit, name='budget_edit'),
path('budgets/action/<int:id>', views.budget_action, name='budget_action'), 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'), path('', views.index, name='treasury'),
] ]

View File

@ -1,6 +1,5 @@
# Society Self-Service # Society Self-Service
# Copyright © 2018–2023 Yingtong Li (RunasSudo) # Copyright © 2018 Yingtong Li (RunasSudo)
# Copyright © 2023 MUMUS Inc.
# #
# This program is free software: you can redistribute it and/or modify # 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 # it under the terms of the GNU Affero General Public License as published by
@ -15,162 +14,84 @@
# You should have received a copy of the GNU Affero General Public License # 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/>. # 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.decorators import login_required
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.core.exceptions import PermissionDenied, ValidationError from django.core.exceptions import PermissionDenied
from django.core.paginator import Paginator
from django.core.validators import validate_email from django.core.validators import validate_email
from django.db import transaction 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.shortcuts import render, redirect
from django.urls import reverse from django.urls import reverse
from django.utils import timezone from django.utils import timezone
from django.views import generic from django.views import generic
from . import aba
from . import models 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 itertools
import json import json
import zipfile
# HELPER DECORATORS @login_required
def index(request):
return render(request, 'sstreasury/index.html')
def uses_budget(viewfunc): @login_required
@functools.wraps(viewfunc) def budget_list(request):
def func(request, id): budgets_action = []
budget = models.Budget.objects.get(id=id) budgets_open = []
budgets_closed = []
for budget in models.Budget.objects.all():
revision = budget.budgetrevision_set.reverse()[0] revision = budget.budgetrevision_set.reverse()[0]
return viewfunc(request, budget, revision) state = models.BudgetState(revision.state)
return func 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)
def budget_viewable(viewfunc): return render(request, 'sstreasury/budget_list.html', {
@functools.wraps(viewfunc) 'budgets_action': budgets_action,
def func(request, budget, revision): 'budgets_open': budgets_open,
if not revision.can_view(request.user): 'budgets_closed': budgets_closed
raise PermissionDenied })
return viewfunc(request, budget, revision)
return func
def budget_editable(viewfunc): @login_required
@functools.wraps(viewfunc) def budget_view(request, id):
def func(request, budget, revision): budget = models.Budget.objects.get(id=id)
if not revision.can_edit(request.user):
raise PermissionDenied
return viewfunc(request, budget, revision)
return func
def uses_claim(viewfunc): if 'revision' in request.GET:
@functools.wraps(viewfunc) revision = budget.budgetrevision_set.get(id=int(request.GET['revision']))
def func(request, id): else:
claim = models.ReimbursementClaim.objects.get(id=id) revision = budget.budgetrevision_set.reverse()[0]
return viewfunc(request, claim)
return func
def claim_viewable(viewfunc): history = list(itertools.chain(budget.budgetrevision_set.all(), revision.budget.budgetcomment_set.all()))
@functools.wraps(viewfunc) history.sort(key=lambda x: x.time, reverse=True)
def func(request, claim):
if not claim.can_view(request.user):
raise PermissionDenied
return viewfunc(request, claim)
return func
def claim_editable(viewfunc): return render(request, 'sstreasury/budget_view.html', {
@functools.wraps(viewfunc) 'revision': revision,
def func(request, claim): 'history': history,
if not claim.can_edit(request.user): 'is_latest': 'revision' not in request.GET
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): def revision_from_form(budget, revision, form):
errors = []
revision.budget = budget revision.budget = budget
if form['name']:
if len(form['name']) > 100:
errors.append('Title must be at most 100 characters')
revision.name = form['name'] revision.name = form['name']
else: revision.date = form['date'] if form['date'] else None
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.comments = form['comments']
revision.state = models.BudgetState.DRAFT.value
revision.revenue = json.loads(form['revenue']) revision.revenue = json.loads(form['revenue'])
revision.revenue_comments = form['revenue_comments'] revision.revenue_comments = form['revenue_comments']
revision.expense = json.loads(form['expense']) revision.expense = json.loads(form['expense'])
revision.expense_comments = form['expense_comments'] 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() revision.save()
contributors = form['contributors'].split('\n')
for contributor in contributors:
validate_email(contributor.strip())
for contributor in contributors: for contributor in contributors:
try: try:
user = User.objects.get(email=contributor.strip()) user = User.objects.get(email=contributor.strip())
@ -182,177 +103,16 @@ def revision_from_form(budget, revision, form):
return revision 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 @login_required
def budget_new(request): def budget_new(request):
if request.method == 'POST': if request.method == 'POST':
try:
with transaction.atomic(): with transaction.atomic():
budget = models.Budget() budget = models.Budget()
budget.save() budget.save()
revision = models.BudgetRevision() revision = models.BudgetRevision()
revision.author = request.user revision.author = request.user
revision.time = timezone.now() 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) 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': if request.POST['submit'] == 'Save':
return redirect(reverse('budget_view', kwargs={'id': budget.id})) return redirect(reverse('budget_view', kwargs={'id': budget.id}))
@ -365,55 +125,46 @@ def budget_new(request):
return render(request, 'sstreasury/budget_edit.html', { return render(request, 'sstreasury/budget_edit.html', {
'revision': revision, 'revision': revision,
'contributors': request.user.email, 'contributors': request.user.email
'errors': []
}) })
@login_required @login_required
@uses_budget def budget_edit(request, id):
@budget_editable
def budget_edit(request, budget, revision):
if request.method == 'POST': if request.method == 'POST':
if request.POST['submit'] == 'Delete': budget = models.Budget.objects.get(id=id)
budget.delete() revision = budget.budgetrevision_set.reverse()[0]
return redirect(reverse('budget_list'))
if request.user not in revision.contributors.all():
raise PermissionDenied
try:
with transaction.atomic(): with transaction.atomic():
new_revision = models.BudgetRevision() revision = models.BudgetRevision()
new_revision.author = request.user revision.author = request.user
new_revision.time = timezone.now() revision.time = timezone.now()
new_revision.ticketing_fee_proportion = settings.TICKETING_FEE_PROPORTION revision = revision_from_form(budget, revision, request.POST)
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': if request.POST['submit'] == 'Save':
return redirect(reverse('budget_view', kwargs={'id': budget.id})) return redirect(reverse('budget_view', kwargs={'id': budget.id}))
else: else:
return redirect(reverse('budget_edit', kwargs={'id': budget.id})) return redirect(reverse('budget_edit', kwargs={'id': budget.id}))
else: else:
budget = models.Budget.objects.get(id=id)
revision = budget.budgetrevision_set.reverse()[0]
return render(request, 'sstreasury/budget_edit.html', { return render(request, 'sstreasury/budget_edit.html', {
'revision': revision, '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 @login_required
@uses_budget def budget_action(request, id):
@budget_viewable budget = models.Budget.objects.get(id=id)
def budget_action(request, budget, revision): revision = budget.budgetrevision_set.reverse()[0]
actions = request.POST['action'].split(',')
if 'Comment' in actions and request.POST.get('comment', None): if request.user not in revision.contributors.all():
with transaction.atomic(): raise PermissionDenied
if request.POST['action'] == 'Comment':
comment = models.BudgetComment() comment = models.BudgetComment()
comment.budget = budget comment.budget = budget
comment.author = request.user comment.author = request.user
@ -421,518 +172,4 @@ def budget_action(request, budget, revision):
comment.content = request.POST['comment'] comment.content = request.POST['comment']
comment.save() 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})) 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

View File

@ -1,42 +0,0 @@
# 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',
})