Add benefactors registration form

parent fd02959b
django.jQuery(function () {
var $ = django.jQuery;
(django.jQuery || jQuery)(function () {
var $ = django.jQuery || jQuery;
$(".payments-row a.process").click(function(e) {
e.preventDefault();
var type = $(e.target).data('type');
......
......@@ -5,29 +5,35 @@ 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 benefactors 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 benefactors registrations.*
This module both provides registration for members and for benefactors. The only difference is the form and view used for their registration since the information we ask from them is different.
New member registration
=======================
New member/benefactor registration
==================================
Frontend
--------
- User enters information
- If the membership type is 'benefactor':
The amount used in the payment is provided during the registration process by the user.
- User accepts privacy policy
This step is obligatory. We do not accept people that do not accept the privacy policy. It's currently implemented as a checkbox in the forms.
- System validates info
- Correct address
- Valid and unique email address
- Checked against existing users
- Privacy policy accepted
- If the selected member type is 'member':
- If the selected membership type is 'member':
- valid and unique student number
- Checked against existing users
- 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)
- If the registration is for a benefactor an email is sent with a link to get references
- Existing members of Thalia add references using the link
Backend
-------
......@@ -37,9 +43,12 @@ Backend
- 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
- Payment model is created (unprocessed at first)
- If the membership type is 'member':
- Amount is calculated based on the selected length ('study' or 'year')
- Values are located in thaliawebsite.settings
- If the membership type is 'benefactor':
- Amount is determined by the value entered during registration
- Email is sent as acceptance confirmation containg instructions for `Payment processing`_
2. Admin rejects registration
- Email is sent as rejection message
......@@ -56,6 +65,8 @@ Frontend
- 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)
- If the renewal is for a benefactor an email is sent with a link to get references
- Existing members of Thalia add references using the link
Backend
-------
......
......@@ -7,7 +7,12 @@ from django.utils.translation import ugettext_lazy as _
from payments.widgets import PaymentWidget
from . import services
from .models import Entry, Registration, Renewal
from .models import Entry, Registration, Renewal, Reference
class ReferenceInline(admin.StackedInline):
model = Reference
extra = 0
def _show_message(admin, request, n, message, error):
......@@ -29,6 +34,7 @@ class RegistrationAdmin(admin.ModelAdmin):
'created_at', 'payment_status')
list_filter = ('status', 'programme', 'payment__type',
'payment__amount')
inlines = (ReferenceInline,)
search_fields = ('first_name', 'last_name', 'email', 'phone_number',
'student_number',)
date_hierarchy = 'created_at'
......@@ -38,6 +44,7 @@ class RegistrationAdmin(admin.ModelAdmin):
'updated_at',
'username',
'length',
'contribution',
'membership_type',
'status',
'payment',
......@@ -109,8 +116,11 @@ class RegistrationAdmin(admin.ModelAdmin):
obj.status == Entry.STATUS_COMPLETED):
return ['status', 'created_at', 'updated_at']
else:
return [field.name for field in self.model._meta.get_fields()
if field.editable and not field.name == 'payment']
return [
field.name for field in self.model._meta.get_fields()
if not field.name in['payment', 'no_references']
and field.editable
]
@staticmethod
def name(obj):
......@@ -179,6 +189,7 @@ class RenewalAdmin(RegistrationAdmin):
'created_at',
'updated_at',
'length',
'contribution',
'membership_type',
'status',
'payment',
......
"""The emails defined by the registrations package"""
from typing import Union
from django.conf import settings
from django.core import mail
from django.template import loader
......@@ -7,10 +9,11 @@ from django.urls import reverse
from django.utils import translation
from django.utils.translation import ugettext_lazy as _
from . import models
from payments.models import Payment
from registrations.models import Registration, Renewal
def send_registration_email_confirmation(registration):
def send_registration_email_confirmation(registration: Registration) -> None:
"""
Send the email confirmation message
......@@ -23,8 +26,8 @@ def send_registration_email_confirmation(registration):
'registrations/email/registration_confirm_mail.txt',
{
'name': registration.get_full_name(),
'confirm_link': '{}{}'.format(
settings.BASE_URL,
'confirm_link': (
settings.BASE_URL +
reverse('registrations:confirm-email',
args=[registration.pk])
)
......@@ -32,7 +35,8 @@ def send_registration_email_confirmation(registration):
)
def send_registration_accepted_message(registration, payment):
def send_registration_accepted_message(registration: Registration,
payment: Payment) -> None:
"""
Send the registration acceptance email
......@@ -51,7 +55,7 @@ def send_registration_accepted_message(registration, payment):
)
def send_registration_rejected_message(registration):
def send_registration_rejected_message(registration: Registration) -> None:
"""
Send the registration rejection email
......@@ -68,29 +72,28 @@ def send_registration_rejected_message(registration):
)
def send_new_registration_board_message(entry):
def send_new_registration_board_message(registration: Registration) -> None:
"""
Send a notification to the board about a new registration
:param entry: the registration entry
:param registration: the registration entry
"""
try:
_send_email(
settings.BOARD_NOTIFICATION_ADDRESS,
'New registration',
'registrations/email/registration_board.txt',
{
'name': entry.registration.get_full_name(),
'url': settings.BASE_URL
+ reverse('admin:registrations_registration_change',
args=[entry.registration.pk])
}
)
except models.Registration.DoesNotExist:
pass
_send_email(
settings.BOARD_NOTIFICATION_ADDRESS,
'New registration',
'registrations/email/registration_board.txt',
{
'name': registration.get_full_name(),
'url': (
settings.BASE_URL +
reverse('admin:registrations_registration_change',
args=[registration.pk])
)
}
)
def send_renewal_accepted_message(renewal, payment):
def send_renewal_accepted_message(renewal: Renewal, payment: Payment) -> None:
"""
Send the renewal acceptation email
......@@ -109,7 +112,7 @@ def send_renewal_accepted_message(renewal, payment):
)
def send_renewal_rejected_message(renewal):
def send_renewal_rejected_message(renewal: Renewal) -> None:
"""
Send the renewal rejection email
......@@ -126,7 +129,7 @@ def send_renewal_rejected_message(renewal):
)
def send_renewal_complete_message(renewal):
def send_renewal_complete_message(renewal: Renewal) -> None:
"""
Send the email completing the renewal
......@@ -143,7 +146,7 @@ def send_renewal_complete_message(renewal):
)
def send_new_renewal_board_message(renewal):
def send_new_renewal_board_message(renewal: Renewal) -> None:
"""
Send a notification to the board about a new renewal
......@@ -155,14 +158,53 @@ def send_new_renewal_board_message(renewal):
'registrations/email/renewal_board.txt',
{
'name': renewal.member.get_full_name(),
'url': settings.BASE_URL
+ reverse('admin:registrations_renewal_change',
args=[renewal.pk])
'url': (
settings.BASE_URL +
reverse('admin:registrations_renewal_change',
args=[renewal.pk])
)
}
)
def _send_email(to, subject, body_template, context):
def send_references_information_message(
entry: Union[Registration, Renewal]) -> None:
"""
Send a notification to the user with information about references
These are required for benefactors who have not been a Thalia member
and do not work for iCIS
:param entry: the registration or renewal entry
"""
if type(entry).__name__ == 'Registration':
email = entry.email
name = entry.get_full_name()
language = entry.language
else:
email = entry.member.email
name = entry.member.get_full_name()
language = entry.member.profile.language
print(language)
with translation.override(language):
_send_email(
email,
_('Information about references'),
'registrations/email/references_information.txt',
{
'name': name,
'reference_link': (
settings.BASE_URL +
reverse('registrations:reference', args=[entry.pk])
)
}
)
def _send_email(to: str, subject: str,
body_template: str, context: dict) -> None:
"""
Easily send an email with the right subject and a body template
......
"""The forms defined by the registrations package"""
from django import forms
from django.forms import TypedChoiceField
from django.urls import reverse_lazy
from django.utils import timezone
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from utils.snippets import datetime_to_lectureyear
from .models import Registration, Renewal
class MemberRegistrationForm(forms.ModelForm):
"""Form for membership registrations"""
class BaseRegistrationForm(forms.ModelForm):
"""Base form for membership registrations"""
birthday = forms.DateField(
widget=forms.widgets.SelectDateWidget(years=[
......@@ -20,9 +22,18 @@ class MemberRegistrationForm(forms.ModelForm):
privacy_policy = forms.BooleanField(
required=True,
label=_('I accept the privacy policy')
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['privacy_policy'].label = mark_safe(_(
'I accept the <a href="{}">privacy policy</a>.').format(
reverse_lazy('privacy-policy')))
class MemberRegistrationForm(BaseRegistrationForm):
"""Form for member registrations"""
this_year = datetime_to_lectureyear(timezone.now())
years = reversed([(x, "{} - {}".format(x, x + 1)) for x in
range(this_year - 20, this_year + 2)])
......@@ -30,25 +41,52 @@ class MemberRegistrationForm(forms.ModelForm):
starting_year = TypedChoiceField(
choices=years,
coerce=int,
empty_value=this_year
empty_value=this_year,
required=False
)
class Meta:
model = Registration
fields = '__all__'
exclude = ['created_at', 'updated_at', 'status', 'username', 'remarks',
exclude = ['created_at', 'updated_at', 'status', 'username',
'payment', 'membership']
class MemberRenewalForm(forms.ModelForm):
class BenefactorRegistrationForm(BaseRegistrationForm):
"""Form for benefactor registrations"""
icis_employee = forms.BooleanField(
required=False,
label=_('I am an employee of iCIS')
)
class Meta:
model = Registration
fields = '__all__'
exclude = ['created_at', 'updated_at', 'status', 'username',
'starting_year', 'programme', 'payment', 'membership']
class RenewalForm(forms.ModelForm):
"""Form for membership renewals"""
privacy_policy = forms.BooleanField(
required=True,
label=_('I accept the privacy policy')
)
icis_employee = forms.BooleanField(
required=False,
label=_('I am an employee of iCIS')
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['privacy_policy'].label = mark_safe(_(
'I accept the <a href="{}">privacy policy</a>.').format(
reverse_lazy('privacy-policy')))
class Meta:
model = Renewal
fields = '__all__'
exclude = ['created_at', 'updated_at', 'status', 'remarks']
exclude = ['created_at', 'updated_at', 'status',
'payment', 'membership']
This diff was suppressed by a .gitattributes entry.
# Generated by Django 2.2 on 2019-04-25 09:07
import django.core.validators
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
('members', '0031_benefactor_model_value'),
('registrations', '0018_benefactor_model_value'),
]
operations = [
migrations.AddField(
model_name='entry',
name='contribution',
field=models.FloatField(blank=True, default=7.5, null=True, validators=[django.core.validators.MinValueValidator(7.5)], verbose_name='contribution'),
),
migrations.AddField(
model_name='entry',
name='no_references',
field=models.BooleanField(default=False, verbose_name='no references required'),
),
migrations.CreateModel(
name='Reference',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('entry', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='registrations.Entry', verbose_name='entry')),
('member', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='members.Member', verbose_name='member')),
],
options={
'unique_together': {('member', 'entry')},
},
),
]
......@@ -5,13 +5,13 @@ from django.conf import settings
from django.contrib.auth import get_user_model
from django.core import validators
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.template.defaultfilters import floatformat
from django.utils import timezone
from django.utils.translation import ugettext_lazy as _
from members.models import Membership, Profile
from registrations import emails
from utils import countries
......@@ -62,6 +62,19 @@ class Entry(models.Model):
MEMBERSHIP_TYPES = [m for m in Membership.MEMBERSHIP_TYPES
if m[0] != Membership.HONORARY]
contribution = models.FloatField(
verbose_name=_('contribution'),
validators=[MinValueValidator(settings.MEMBERSHIP_PRICES['year'])],
default=settings.MEMBERSHIP_PRICES['year'],
blank=True,
null=True,
)
no_references = models.BooleanField(
verbose_name=_('no references required'),
default=False
)
membership_type = models.CharField(
verbose_name=_('membership type'),
choices=MEMBERSHIP_TYPES,
......@@ -96,8 +109,28 @@ class Entry(models.Model):
self.status != self.STATUS_REJECTED):
self.updated_at = timezone.now()
if (self.contribution is not None and
self.membership_type != Membership.BENEFACTOR):
self.contribution = None
elif self.membership_type == Membership.BENEFACTOR:
self.length = self.MEMBERSHIP_YEAR
super().save(force_insert, force_update, using, update_fields)
def clean(self):
super().clean()
errors = {}
if (self.contribution is None and
self.membership_type == Membership.BENEFACTOR):
errors.update({
'contribution':
_('This field is required for benefactors.')
})
if errors:
raise ValidationError(errors)
def __str__(self):
try:
return self.registration.__str__()
......@@ -294,12 +327,6 @@ class Registration(Entry):
if errors:
raise ValidationError(errors)
def save(self, *args, **kwargs):
send_confirm_email = self.pk is None
super().save(*args, **kwargs)
if send_confirm_email:
emails.send_registration_email_confirmation(self)
def __str__(self):
return '{} {} ({})'.format(self.first_name, self.last_name, self.email)
......@@ -329,7 +356,8 @@ class Renewal(Entry):
errors = {}
if Renewal.objects.filter(
member=self.member, status=Entry.STATUS_REVIEW).exists():
member=self.member, status=Entry.STATUS_REVIEW
).exclude(pk=self.pk).exists():
raise ValidationError(_('You already have a renewal '
'request queued for review.'))
......@@ -373,3 +401,28 @@ class Renewal(Entry):
class Meta:
verbose_name = _('renewal')
verbose_name_plural = _('renewals')
class Reference(models.Model):
"""Describes a reference of a member for a potential member"""
member = models.ForeignKey(
'members.Member',
on_delete=models.CASCADE,
verbose_name=_('member'),
blank=False,
null=False,
)
entry = models.ForeignKey(
'registrations.Entry',
on_delete=models.CASCADE,
verbose_name=_('entry'),
blank=False,
null=False
)
def __str__(self):
return f'Reference from {self.member} for {self.entry}'
class Meta:
unique_together = ('member', 'entry')
"""The services defined by the registrations package"""
import string
import unicodedata
from typing import Union
from django.conf import settings
from django.contrib.admin.models import LogEntry, CHANGE
from django.contrib.admin.options import get_content_type_for_model
from django.contrib.auth import get_user_model
from django.db.models import Q
from django.db.models import Q, QuerySet
from django.utils import timezone
import members
from members.models import Membership, Profile
from members.models import Membership, Profile, Member
from payments.models import Payment
from registrations import emails
from registrations.models import Entry, Registration, Renewal
from utils.snippets import datetime_to_lectureyear
def _generate_username(registration):
def _generate_username(registration: Registration) -> str:
"""
Create username from first and lastname
......@@ -39,7 +40,7 @@ def _generate_username(registration):
return username
def check_unique_user(entry):
def check_unique_user(entry: Entry) -> bool:
"""
Check that the username and email address of the entry are unique.
......@@ -62,7 +63,7 @@ def check_unique_user(entry):
return True
def confirm_entry(queryset):
def confirm_entry(queryset: QuerySet) -> int:
"""
Confirm all entries in the queryset
......@@ -77,10 +78,11 @@ def confirm_entry(queryset):
return rows_updated
def reject_entries(user_id, queryset):
def reject_entries(user_id: int, queryset: QuerySet) -> int:
"""
Reject all entries in the queryset
:param user_id: Id of the user executing this action
:param queryset: queryset of entries
:type queryset: Queryset[Entry]
:return: number of updated rows
......@@ -117,10 +119,11 @@ def reject_entries(user_id, queryset):
return rows_updated
def accept_entries(user_id, queryset):
def accept_entries(user_id: int, queryset: QuerySet) -> int:
"""
Accept all entries in the queryset
:param user_id: Id of the user executing this action
:param queryset: queryset of entries
:type queryset: Queryset[Entry]
:return: number of updated rows
......@@ -174,10 +177,11 @@ def accept_entries(user_id, queryset):
return len(updated_entries)
def revert_entry(user_id, entry):
def revert_entry(user_id: int, entry: Entry) -> None:
"""
Revert status of entry to review so that it can be corrected
:param user_id: Id of the user executing this action
:param entry: Entry that should be reverted
"""
if not (entry.status in [Entry.STATUS_ACCEPTED, Entry.STATUS_REJECTED]):
......@@ -201,8 +205,6 @@ def revert_entry(user_id, entry):
except Renewal.DoesNotExist:
pass
print(log_obj)