Add registrations feature

Move members welcome email to emails.py

Remove BecomeAMemberDocument model

Add registrations app with models

Add emails.py and email templates

Model changes:
- Remove email_confirmed boolean from Registration model
- Change default value of Registration model status to 'confirm'
- Make Registration model email unique

Add first model tests for Registration

Write clean() for Registration

Fix PEP8 errors

Add init file to tests directory

Make Membership types constants instead of using the string value everywhere

Change membership lengths to constants in Entry model

Mention upgrade behaviour in registrations README

Use constants in registrations models test

Use constants for Entry status and fix membership_type argument in models test

Convert payment types to constants

Override translation language in registration emails by preferred language

Add services.py and tests

Implemented services.py

Add admin.py, fix docs in services.py and add app to sphinx

Wrote email contents

Make Registration email not unique

Fix call to renewal complete email in services.py

Add mail outbox assert to test_process_payment

Fix email formatting and do not completely block payment permissions

Make welcome mail non-trimmed

Change model admin changeform and add accept/reject buttons for registration and renewal

Add process buttons to payment admin and add permissions for reviewing and processing

Make some changes to the registrations admin (RenewalAdmin now inherits from RegistrationsAdmin) and add some tests

Add confirmation messages to custom admin calls, and redirect back to change form

Improve automatic username generation

Prevent processing payment for member with already active study membership

Rewrite registrations views to classes

Add tests for views

Fix sending confirmation email and related tests

Send confirmation mail after registration creation

Change README to reflect new supposed behaviour

Add frontend views

Update migration file to reflect model changes

Update view tests to reflect url changes

Add tests for forms

Change birthday field to SelectDateWidget

Generate username when accepting registration, not when processing payment

Change README to reflect new rules

Improve renewal admin

Move programme and starting year checks to model

Rename views and form to be member only

Make changes to model

Fix typo's and price for renewal

Fix tests

Move renewal validation to model instead of form

Change rules for processing in August

Update migration

Make renewal member field not readonly for new obj

Fix lecture year in test

Add confirmation to accept/reject buttons

Add localization for registrations

Change until date for new memberships to 01-09

Add processing date to payments

Add updated_at field to Entry model

Creation of renewal determines price

Fix tests

Always start membership renewals in August in September

Add board notifications

Move become a member page to registrations app

Update localisation

Add link to registration renewal on account page

Change file names and add link to documents page
parent 5f5421ca
......@@ -16,6 +16,7 @@ website
partners
photos
pizzas
registrations
thabloid
thaliawebsite
utils
registrations package
=====================
.. automodule:: registrations
:members:
:undoc-members:
:show-inheritance:
Submodules
----------
registrations\.admin module
---------------------------
.. automodule:: registrations.admin
:members:
:undoc-members:
:show-inheritance:
registrations\.apps module
--------------------------
.. automodule:: registrations.apps
:members:
:undoc-members:
:show-inheritance:
registrations\.emails module
----------------------------
.. automodule:: registrations.emails
:members:
:undoc-members:
:show-inheritance:
registrations\.models module
----------------------------
.. automodule:: registrations.models
:members:
:undoc-members:
:show-inheritance:
registrations\.services module
------------------------------
.. automodule:: registrations.services
:members:
:undoc-members:
:show-inheritance:
registrations\.urls module
--------------------------
.. automodule:: registrations.urls
:members:
:undoc-members:
:show-inheritance:
"""
This module registers admin pages for the models
"""
import csv
import datetime
from django.contrib import admin
......@@ -10,7 +11,6 @@ from django.db.models import Q
from django.http import HttpResponse
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
import csv
from . import forms, models
......@@ -145,8 +145,6 @@ class MemberAdmin(UserAdmin):
return False
admin.site.register(models.BecomeAMemberDocument)
# re-register User admin
admin.site.unregister(User)
admin.site.register(User, UserAdmin)
......@@ -107,3 +107,17 @@ def send_expiration_announcement(dry_run=False):
{'members': members}),
connection=connection,
)
def send_welcome_message(user, password, language):
with translation.override(language):
email_body = loader.render_to_string(
'members/email/welcome.txt',
{
'full_name': user.get_full_name(),
'username': user.username,
'password': password
})
user.email_user(
_('Welcome to Study Association Thalia'),
email_body)
......@@ -27,7 +27,7 @@
"username": "testuser",
"first_name": "",
"last_name": "",
"email": "",
"email": "testuser@example.com",
"is_staff": true,
"is_active": true,
"date_joined": "2016-07-07T12:00:21Z",
......@@ -53,6 +53,24 @@
"user_permissions": []
}
},
{
"model": "auth.user",
"pk": 4,
"fields": {
"password": "",
"last_login": null,
"is_superuser": false,
"username": "testuser3",
"first_name": "",
"last_name": "",
"email": "",
"is_staff": true,
"is_active": true,
"date_joined": "2016-07-07T14:50:26Z",
"groups": [],
"user_permissions": []
}
},
{
"model": "members.profile",
"pk": 1,
......@@ -131,6 +149,32 @@
"bank_account": ""
}
},
{
"model": "members.profile",
"pk": 4,
"fields": {
"user": 4,
"programme": null,
"student_number": "",
"address_street": "testuser 3",
"address_street2": "",
"address_postal_code": "6525 TE",
"address_city": "Nijmegen",
"phone_number": "",
"emergency_contact": "",
"emergency_contact_phone_number": "",
"birthday": "2016-07-07",
"show_birthday": true,
"website": "",
"profile_description": "",
"nickname": "",
"display_name_preference": "full",
"language": "nl",
"receive_optin": true,
"direct_debit_authorized": false,
"bank_account": ""
}
},
{
"model": "members.membership",
"pk": 1,
......
from __future__ import unicode_literals
from django import forms
from django.contrib.auth.models import User
from django.contrib.auth.forms import UserChangeForm as BaseUserChangeForm
from django.contrib.auth.forms import UserCreationForm as BaseUserCreationForm
from django.template import loader
from django.utils import translation
from django.utils.translation import ugettext
from django.contrib.auth.models import User
from django.utils.translation import ugettext_lazy as _
from .models import Profile
from members import emails
class ProfileForm(forms.ModelForm):
......@@ -74,17 +72,7 @@ class UserCreationForm(BaseUserCreationForm):
language = str(self.data.get('profile-0-language', 'en'))
if language not in ('nl', 'en'):
language = 'en'
with translation.override(language):
email_body = loader.render_to_string(
'members/email/welcome.txt',
{
'full_name': user.get_full_name(),
'username': user.username,
'password': password
})
user.email_user(
ugettext('Welcome to Study Association Thalia'),
email_body)
emails.send_welcome_message(user, password, language)
return user
class Meta:
......
This diff was suppressed by a .gitattributes entry.
# -*- coding: utf-8 -*-
# Generated by Django 1.11 on 2017-07-23 14:41
from __future__ import unicode_literals
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('members', '0018_create_proxy_member'),
]
operations = [
migrations.DeleteModel(
name='BecomeAMemberDocument',
),
]
......@@ -16,7 +16,6 @@ from localflavor.generic.models import IBANField
from activemembers.models import Committee
from utils.snippets import datetime_to_lectureyear
from utils.validators import validate_file_extension
from PIL import Image
import os
......@@ -440,10 +439,14 @@ class Profile(models.Model):
class Membership(models.Model):
MEMBER = 'member'
SUPPORTER = 'supporter'
HONORARY = 'honorary'
MEMBERSHIP_TYPES = (
('member', _('Member')),
('supporter', _('Supporter')),
('honorary', _('Honorary Member')))
(MEMBER, _('Member')),
(SUPPORTER, _('Supporter')),
(HONORARY, _('Honorary Member')))
type = models.CharField(
max_length=40,
......@@ -476,17 +479,6 @@ class Membership(models.Model):
return not self.until or self.until > timezone.now().date()
class BecomeAMemberDocument(models.Model):
name = models.CharField(max_length=200)
file = models.FileField(
upload_to='members/',
validators=[validate_file_extension],
)
def __str__(self):
return self.name
def gen_stats_member_type(member_types):
total = dict()
for member_type in member_types:
......
from django.contrib import sitemaps
from django.urls import reverse
from . import models
class StaticViewSitemap(sitemaps.Sitemap):
priority = 0.5
......@@ -15,17 +13,6 @@ class StaticViewSitemap(sitemaps.Sitemap):
return reverse(item)
class BecomeAMemberDocumentSitemap(sitemaps.Sitemap):
priority = 0.1
def items(self):
return models.BecomeAMemberDocument.objects.all()
def location(self, item):
return reverse('members:become-a-member-document', args=(item.pk,))
sitemap = {
'members-static': StaticViewSitemap,
'members-become-documents': BecomeAMemberDocumentSitemap,
}
......@@ -21,6 +21,11 @@
<p>{% blocktrans %}Take a look at your own profile.{% endblocktrans %}</p>
</div>
<div>
<a href="{% url 'registrations:renew' %}">{% trans "manage membership"|capfirst %}</a>
<p>{% blocktrans %}Get information about your membership or renew it.{% endblocktrans %}</p>
</div>
<hr>
<div>
......
{% extends 'documents/generic.html' %}
{% block "downloadlink" %}{% url 'members:become-a-member-document' document.pk %}{% endblock %}
{% load i18n %}{% blocktrans trimmed %}Dear {{ full_name }},
{% load i18n %}{% blocktrans %}Dear {{ full_name }},
Welcome to Study Association Thalia! You now have an account and can
log in at https://thalia.nu/. This is your username and password:
log in at https://thalia.nu/.
Your username is: {{ username }}
Your password is: {{ password }}
Please also check the information on your profile.
Cheers,
With kind regards,
Thalia
{% endblocktrans %}
The board of Study Association Thalia
————
This email was automatically generated.{% endblocktrans %}
from django.conf.urls import include, url
from django.conf.urls import url
from . import views
app_name = "members"
urlpatterns = [
url('^become-a-member-document/(?P<pk>[0-9]*)',
views.get_become_a_member_document,
name='become-a-member-document'),
url('^profile/(?P<pk>[0-9]*)$', views.profile, name='profile'),
url('^profile/edit/$', views.edit_profile, name='edit-profile'),
url('^members/iban-export/$', views.iban_export, name='iban-export'),
......
import csv
import json
import os
from datetime import date, datetime
from django.contrib.auth.decorators import login_required, permission_required
......@@ -8,9 +7,7 @@ from django.core.paginator import EmptyPage, PageNotAnInteger, Paginator
from django.db.models import Q
from django.http import HttpResponse
from django.shortcuts import get_object_or_404, render
from django.utils.text import slugify
from django.utils.translation import gettext as _
from sendfile import sendfile
from members.services import member_achievements
from members.models import Member
......@@ -191,20 +188,6 @@ def iban_export(request):
return response
def become_a_member(request):
context = {'documents': models.BecomeAMemberDocument.objects.all()}
return render(request, 'singlepages/become_a_member.html', context)
def get_become_a_member_document(request, pk):
document = get_object_or_404(models.BecomeAMemberDocument, pk=int(pk))
ext = os.path.splitext(document.file.path)[1]
return sendfile(request,
document.file.path,
attachment=True,
attachment_filename=slugify(document.name) + ext)
def statistics(request):
member_types = ("member", "supporter", "honorary")
......
Registrations
=====
This document explains how the registrations module behaviour is defined.
The behaviour of upgrading an existing 'year' membership to a 'study' membership (until graduation) is taken from the HR. If the HR ever changes this behaviour should be changed to reflect those changes.
_Note that registrations and renewals for supporters are implemented in the models, there are simply no views providing this functionality. If we ever want to implement this then it would be best to create a complete new form just for supporter registrations._
## New member registration
### Frontend
- User enters information
- User accepts privacy policy
- System validates info
- Correct address
- Valid and unique email address
- Checked against existing users
- Privacy policy accepted
- If the selected member type is 'member':
- valid and unique student number
- selected programme
- cohort
- Registration model created (status: Awaiting email confirmation)
- Email address confirmation sent
- User confirms email address
- Registration model status changed (status: Ready for review)
### Backend
1. Admin accepts registration
- System checks if username is unique
- If it's not unique a username can be entered manually
- If it's still not unique the registration cannot be accepted
- If it's unique the generated username will be added to the registration
- Payment model is created (processed: False)
- Amount is calculated based on the selected length ('study' or 'year')
- Values are located in thaliawebsite.settings
- Email is sent as acceptance confirmation containg instructions for [payment](#payment-processing)
2. Admin rejects registration
- Email is sent as rejection message
## Existing user membership renewal
### Frontend
- User enters information (length, type)
- If latest membership has not ended yet: always allow 'study' length
- If latest membership has ended or ends within 1 month: also allow 'year' length
- If latest membership is 'study' and did not end: do not allow renewal
- Renewal model created (status: Ready for review)
### Backend
1. Admin accepts renewal
- Payment model is created (processed: False)
- Amount is calculated based on selected length ('study' or 'year')
- Values are located in thaliawebsite.settings
- If the current membership has not ended yet and an until date is present for that membership and
the selected length is 'study' the amount will be price['study'] - price['year']
- Email is sent as acceptance confirmation containg instructions for [payment](#payment-processing)
2. Admin rejects renewal
- Email is sent as rejection message
## Payment processing
### Backend
- Admin (or the system, if automated using e.g. iDeal) processes payment
- If this is a Registration model then User and Member models are created
- If this is a Renewal model then the Member is retrieved
- A membership is added to the provided Member model based on the provided length
- If the __latest__ (_not current, since there may have been some time between asking for the upgrade and accepting it_) membership has an until date and
the selected length is 'study' that membership will be updated to have None as until date. No new membership will be created.
- During a lecture year the until date will be the 31 August of the lecture year + 1. Thus is you process payments in November 2016 that means the memberships will end on 31 August 2017
- For payments processed in August the lecture year will be increased by 1. So if you process payments in August 2017 that means the memberships will end on 31 August 2018.
- Payment confirmation sent (if this is a Renewal model)
default_app_config = 'registrations.apps.RegistrationsConfig'
from django.contrib import admin, messages
from django.contrib.admin.utils import model_ngettext
from django.utils.html import format_html
from django.utils.translation import ugettext_lazy as _
from registrations import services
from .models import Entry, Payment, Registration, Renewal
def _show_message(admin, request, n, message, error):
if n == 0:
admin.message_user(request, error, messages.ERROR)
else:
admin.message_user(request, message % {
"count": n,
"items": model_ngettext(admin.opts, n)
}, messages.SUCCESS)
@admin.register(Registration)
class RegistrationAdmin(admin.ModelAdmin):
list_display = ('name', 'email', 'status',
'created_at', 'payment_status')
list_filter = ('status', 'programme', 'payment__processed',
'payment__amount')
search_fields = ('first_name', 'last_name', 'email', 'phone_number',
'student_number',)
date_hierarchy = 'created_at'
fieldsets = (
(_('Application information'), {
'fields': ('created_at',
'updated_at',
'username',
'length',
'membership_type',
'status',
'remarks',)
}),
(_('Personal information'), {
'fields': ('first_name',
'last_name',
'birthday',
'email',
'phone_number',)
}),
(_('Address'), {
'fields': ('address_street',
'address_street2',
'address_postal_code',
'address_city',)
}),
(_('University information'), {
'fields': ('student_number',
'programme',
'starting_year',)
}),
)
actions = ['accept_selected', 'reject_selected']
def changeform_view(self, request, object_id=None, form_url='',
extra_context=None):
obj = None
if (object_id is not None and
request.user.has_perm('registrations.review_entries')):
obj = Entry.objects.get(id=object_id)
if not (obj.status == Entry.STATUS_REVIEW):
obj = None
return super().changeform_view(
request, object_id, form_url, {'entry': obj})
def get_actions(self, request):
actions = super().get_actions(request)
if not request.user.has_perm('registrations.review_entries'):
del(actions['accept_selected'])
del(actions['reject_selected'])
return actions
def get_readonly_fields(self, request, obj=None):
if obj is None or not (obj.status == Entry.STATUS_REJECTED or
obj.status == Entry.STATUS_ACCEPTED):
return ['status', 'created_at', 'updated_at']
else:
return [field.name for field in self.model._meta.get_fields()
if field.editable]
@staticmethod
def name(obj):
return obj.get_full_name()
@staticmethod
def payment_status(obj):
try:
payment = obj.payment
processed_str = (_('Processed') if payment.processed else
_('Unprocessed'))
return format_html('<a href="{link}">{title}</a>'
.format(link=payment.get_admin_url(),
title=processed_str))
except Payment.DoesNotExist:
return '-'
def reject_selected(self, request, queryset):
if request.user.has_perm('registrations.review_entries'):
rows_updated = services.reject_entries(queryset)
_show_message(
self, request, rows_updated,
message=_("Successfully rejected %(count)d %(items)s."),
error=_('The selected registration(s) could not be rejected.')
)
reject_selected.short_description = _('Reject selected registrations')
def accept_selected(self, request, queryset):
if request.user.has_perm('registrations.review_entries'):
rows_updated = services.accept_entries(queryset)
_show_message(
self, request, rows_updated,
message=_("Successfully accepted %(count)d %(items)s."),
error=_('The selected registration(s) could not be accepted.')
)
accept_selected.short_description = _('Accept selected registrations')
@admin.register(Renewal)
class RenewalAdmin(RegistrationAdmin):
list_display = ('name', 'email', 'status',
'created_at', 'payment_status',)
list_filter = ('status', 'payment__processed', 'payment__amount')
search_fields = ('member__first_name', 'member__last_name',
'member__email', 'member__profile__phone_number',
'member__profile__student_number',)
date_hierarchy = 'created_at'
fieldsets = (
(_('Application information'), {
'fields': (
'created_at',
'updated_at',
'length',
'membership_type',
'status',
'remarks',
'member',)
}),
)
def get_readonly_fields(self, request, obj=None):
fields = super().get_readonly_fields(request, obj)
if 'member' not in fields and obj is not None:
return fields + ['member']
return fields
@staticmethod
def name(obj):
return obj.member.get_full_name()
@staticmethod
def email(obj):
return obj.member.email
@admin.register(Payment)
class PaymentAdmin(admin.ModelAdmin):
list_display = ('entry', 'created_at', 'amount',
'processed', 'processing_date', 'type')
list_filter = ('processed', 'amount',)
date_hierarchy = 'created_at'
fields = ('created_at', 'entry', 'amount',
'type', 'processed', 'processing_date')
readonly_fields = ('created_at', 'amount', 'processed',
'type', 'entry', 'processing_date')
actions = ['process_cash_selected', 'process_card_selected']
def changeform_view(self, request, object_id=None, form_url='',
extra_context=None):
obj = None
if (object_id is not None and
request.user.has_perm('registrations.process_payments')):
obj = Payment.objects.get(id=object_id)
if obj.processed:
obj = None
return super().changeform_view(
request, object_id, form_url, {'payment': obj})
def get_actions(self, request):
actions = super().get_actions(request)
if not request.user.has_perm('registrations.process_payments'):
del(actions['process_cash_selected'])
del(actions['process_card_selected'])
return actions
def process_cash_selected(self, request, queryset):
if request.user.has_perm('registrations.process_payments'):
updated_payments = services.process_payment(queryset,
'cash_payment')
self._process_feedback(request, updated_payments)
process_cash_selected.short_description = _(
'Process selected payments (cash)')
def process_card_selected(self, request, queryset):
if request.user.has_perm('registrations.process_payments'):
updated_payments = services.process_payment(queryset,
'card_payment')
self._process_feedback(request, updated_payments)
process_card_selected.short_description = _(
'Process selected payments (card)')