From 80c585111a523111596b678ceb94139148b21d62 Mon Sep 17 00:00:00 2001 From: Yingtong Li Date: Wed, 6 Mar 2019 10:43:32 +1100 Subject: [PATCH] Membership onboarding --- .gitignore | 1 + requirements.txt | 3 + selfserv/jinja2.py | 7 +- .../jinja2/ssmembership/email/onboard.html | 65 ++++++++++ .../jinja2/ssmembership/email/onboard.txt | 33 +++++ .../jinja2/ssmembership/onboard/complete.html | 29 +++++ .../jinja2/ssmembership/onboard/index.html | 47 +++++++ .../jinja2/ssmembership/onboard/review.html | 122 ++++++++++++++++++ .../management/commands/sendonboardemail.py | 112 ++++++++++++++++ ssmembership/monboard.py | 77 +++++++++++ ssmembership/urls.py | 4 + ssmembership/views.py | 95 ++++++++++++++ 12 files changed, 594 insertions(+), 1 deletion(-) create mode 100644 ssmembership/jinja2/ssmembership/email/onboard.html create mode 100644 ssmembership/jinja2/ssmembership/email/onboard.txt create mode 100644 ssmembership/jinja2/ssmembership/onboard/complete.html create mode 100644 ssmembership/jinja2/ssmembership/onboard/index.html create mode 100644 ssmembership/jinja2/ssmembership/onboard/review.html create mode 100644 ssmembership/management/commands/sendonboardemail.py create mode 100644 ssmembership/monboard.py diff --git a/.gitignore b/.gitignore index 4bf418f..c4b22e6 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,6 @@ venv* *.sqlite3 +*.db __pycache__ settings.py diff --git a/requirements.txt b/requirements.txt index 4f07ec6..93656bf 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 diff --git a/selfserv/jinja2.py b/selfserv/jinja2.py index dcfc13f..a9bbc88 100644 --- a/selfserv/jinja2.py +++ b/selfserv/jinja2.py @@ -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 diff --git a/ssmembership/jinja2/ssmembership/email/onboard.html b/ssmembership/jinja2/ssmembership/email/onboard.html new file mode 100644 index 0000000..f9f5cde --- /dev/null +++ b/ssmembership/jinja2/ssmembership/email/onboard.html @@ -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 . +#} + +{% block content %} + + + + + + + +
+
Welcome to {{ import('django.conf').settings.ORG_NAME }}!
+

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 clicking the button 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.

+ +
+

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 }}.

+
+ + + + + + + + + +
+   +
+ + +{% endblock content %} diff --git a/ssmembership/jinja2/ssmembership/email/onboard.txt b/ssmembership/jinja2/ssmembership/email/onboard.txt new file mode 100644 index 0000000..7bd3d4b --- /dev/null +++ b/ssmembership/jinja2/ssmembership/email/onboard.txt @@ -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 . +#} +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 }}. diff --git a/ssmembership/jinja2/ssmembership/onboard/complete.html b/ssmembership/jinja2/ssmembership/onboard/complete.html new file mode 100644 index 0000000..2ce1e51 --- /dev/null +++ b/ssmembership/jinja2/ssmembership/onboard/complete.html @@ -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 . +#} + +{% block title %}Membership activation complete{% endblock %} + +{% block content %} +

Membership activation complete

+ +

Your membership activation has been successfully processed.

+ +

You can view and edit your membership details by logging in.

+{% endblock %} diff --git a/ssmembership/jinja2/ssmembership/onboard/index.html b/ssmembership/jinja2/ssmembership/onboard/index.html new file mode 100644 index 0000000..b03e2f6 --- /dev/null +++ b/ssmembership/jinja2/ssmembership/onboard/index.html @@ -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 . +#} + +{% block title %}Membership activation{% endblock %} + +{% block content %} +

Membership activation

+ +

To activate a new membership, please enter your details below:

+ +
+
+ +
+ +
+
+
+ +
+ +
Enter the email address that is registered with MUMUS. This is the email which received the purchased ticket/receipt.
+
+
+
+
+ + +
+{% endblock %} diff --git a/ssmembership/jinja2/ssmembership/onboard/review.html b/ssmembership/jinja2/ssmembership/onboard/review.html new file mode 100644 index 0000000..8ad55e6 --- /dev/null +++ b/ssmembership/jinja2/ssmembership/onboard/review.html @@ -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 . +#} + +{% block title %}Membership activation{% endblock %} + +{% block content %} +

Membership activation

+ + {% if not member %} +

The details you entered do not match our records, or the membership has already been activated. Click here to try again.

+ {% else %} +

Please check the following details and update them if necessary:

+ +
+ {% if errors %} +
    + {% for error in errors %} +
  • {{ error }}
  • + {% endfor %} +
+ {% endif %} +
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+

MUMUS Mail

+ +
+ + +
+
+ +
+ {% for group in import('sspromotions.models').Group.objects.all() %} + {% if group.subscribable %} +
+
+ + +
+
+ {% endif %} + {% endfor %} +

MUMUS Mail is personalised for you. Choose the groups that you would like to see first in each edition of MUMUS Mail.

+
+
+
+ + + + +
+ {% endif %} +{% endblock %} + +{% block script %} + {{ super() }} + {% if member %} + + {% endif %} +{% endblock %} diff --git a/ssmembership/management/commands/sendonboardemail.py b/ssmembership/management/commands/sendonboardemail.py new file mode 100644 index 0000000..ab0d17f --- /dev/null +++ b/ssmembership/management/commands/sendonboardemail.py @@ -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 . + +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]) diff --git a/ssmembership/monboard.py b/ssmembership/monboard.py new file mode 100644 index 0000000..1cefe98 --- /dev/null +++ b/ssmembership/monboard.py @@ -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 . + +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() diff --git a/ssmembership/urls.py b/ssmembership/urls.py index a19ff61..81c6cfe 100644 --- a/ssmembership/urls.py +++ b/ssmembership/urls.py @@ -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'), ] diff --git a/ssmembership/views.py b/ssmembership/views.py index a7e408d..10d56a3 100644 --- a/ssmembership/views.py +++ b/ssmembership/views.py @@ -14,6 +14,8 @@ # You should have received a copy of the GNU Affero General Public License # along with this program. If not, see . +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')