Membership onboarding

This commit is contained in:
Yingtong Li 2019-03-06 10:43:32 +11:00
parent 72f934db70
commit 80c585111a
Signed by: RunasSudo
GPG Key ID: 7234E476BF21C61A
12 changed files with 594 additions and 1 deletions

1
.gitignore vendored
View File

@ -1,5 +1,6 @@
venv*
*.sqlite3
*.db
__pycache__
settings.py

View File

@ -5,3 +5,6 @@ jsonfield==2.0.2
Pillow==5.4.1
Markdown==3.0.1
google-api-python-client==1.7.7
django-ratelimit==2.0.0
boto3==1.9.86
premailer==3.2.0

View File

@ -22,11 +22,16 @@ from django.conf import settings
from django.urls import reverse
from django.utils import timezone
from jinja2 import Environment, Markup
from jinja2 import Environment, Markup, select_autoescape
import importlib
def environment(**options):
options['autoescape'] = select_autoescape(
disabled_extensions=('txt',),
default_for_string=True,
default=True,
)
env = Environment(**options)
env.globals.update({
'import': importlib.import_module, # forgive me for I have sinned

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

@ -0,0 +1,29 @@
{% 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 complete{% endblock %}
{% block content %}
<h1>Membership activation complete</h1>
<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>
{% endblock %}

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 activation{% endblock %}
{% block content %}
<h1>Membership activation</h1>
<p>To activate a new membership, please enter your details below:</p>
<form class="ui form" method="POST" action="{{ url('monboard_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 received the purchased ticket/receipt.</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

@ -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

@ -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])

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,4 +20,8 @@ from . import views
urlpatterns = [
path('', views.index, name='membership'),
path('onboard/', views.onboard_index, name='monboard_index'),
path('onboard/signed', views.onboard_signed, name='monboard_signed'),
path('onboard/search', views.onboard_search, name='monboard_search'),
path('onboard/save', views.onboard_save, name='monboard_save'),
]

View File

@ -14,6 +14,8 @@
# 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 ratelimit.decorators import ratelimit
from django.contrib.auth.decorators import login_required
from django.conf import settings
@ -22,6 +24,7 @@ from django.http import HttpResponse
from django.shortcuts import render, redirect
from django.urls import reverse
from . import monboard
from . import models
@login_required
@ -64,3 +67,95 @@ def index(request):
group.subscribe_member(member, False)
return render(request, 'ssmembership/index.html', {'member': member, 'years': models.Member.YEARS})
def onboard_index(request):
return render(request, 'ssmembership/onboard/index.html')
def onboard_signed(request):
if 'email' not in request.GET:
return HttpResponse('Expected an email address', status=400)
if 'sig' not in request.GET:
return HttpResponse('Expected a signature parameter', status=400)
sig_expected = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), request.GET['email'].encode('utf-8'), 'sha256').hexdigest()
if not hmac.compare_digest(sig_expected, request.GET['sig']):
return HttpResponse('Invalid signature', status=403)
member = monboard.by_email(request.GET['email'])
return render(request, 'ssmembership/onboard/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': member.email if member else None,
'sig': sig_expected
})
@ratelimit(key=settings.RATELIMIT_KEY, rate='100/h')
def onboard_search(request):
if request.method != 'POST':
return redirect(reverse('onboard_index'))
if request.limited:
return HttpResponse('Too many requests', status=429)
member = monboard.by_email(request.POST['email'])
if member and member.student_id != request.POST['student_id']:
member = None
return render(request, 'ssmembership/onboard/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': member.email if member else None,
'sig': hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), member.email.encode('utf-8'), 'sha256').hexdigest() if member else None
})
def onboard_save(request):
if request.method != 'POST':
return redirect(reverse('onboard_index'))
sig_expected = hmac.new(settings.SECRET_KEY_MEMBERSIG.encode('utf-8'), request.POST['email_orig'].encode('utf-8'), 'sha256').hexdigest()
if not hmac.compare_digest(sig_expected, request.POST['sig']):
return HttpResponse('Invalid signature', status=403)
member = monboard.by_email(request.POST['email_orig'])
if not member:
return render(request, 'ssmembership/onboard/review.html', {
'member': member
})
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.is_msa = True if request.POST['is_msa'] == '1' else '0'
errors = member.validation_problems()
if models.Member.objects.filter(email=request.POST['email']).count() > 0:
errors.append('Member with this email already exists')
if len(errors) > 0:
return render(request, 'ssmembership/onboard/review.html', {
'member': member,
'years': models.Member.YEARS,
'email_orig': request.POST['email_orig'],
'sig': request.POST['sig'],
'errors': errors
})
with transaction.atomic():
member.save()
# Update bulletin
import sspromotions.models
sspromotions.models.BulletinSubscription.set_member_subscribed(member, True if request.POST['bulletin_subscribe'] == '1' else False)
for group in sspromotions.models.Group.objects.filter(subscribable=True).all():
if ('bulletin_group_' + str(group.id)) in request.POST and request.POST['bulletin_group_' + str(group.id)]:
group.subscribe_member(member, True)
else:
group.subscribe_member(member, False)
monboard.delete_by_email(request.POST['email_orig'])
return render(request, 'ssmembership/onboard/complete.html')