Coverage for website/registrations/models.py: 100.00%
168 statements
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
« prev ^ index » next coverage.py v7.6.7, created at 2025-08-14 10:31 +0000
1import string
2import unicodedata
3import uuid
5from django.conf import settings
6from django.contrib.auth import get_user_model
7from django.core import validators
8from django.core.exceptions import ValidationError
9from django.core.validators import MinValueValidator, RegexValidator
10from django.db import models
11from django.template.defaultfilters import floatformat
12from django.utils import timezone
13from django.utils.translation import gettext_lazy as _
15from localflavor.generic.countries.sepa import IBAN_SEPA_COUNTRIES
16from localflavor.generic.models import BICField, IBANField
18from members.models import Membership, Profile
19from payments.models import PaymentAmountField
20from utils import countries
23class Entry(models.Model):
24 """Describes a registration entry."""
26 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False)
28 created_at = models.DateTimeField(_("created at"), default=timezone.now)
29 updated_at = models.DateTimeField(_("updated at"), default=timezone.now)
31 STATUS_CONFIRM = "confirm"
32 STATUS_REVIEW = "review"
33 STATUS_REJECTED = "rejected"
34 STATUS_ACCEPTED = "accepted"
35 STATUS_COMPLETED = "completed"
37 STATUS_TYPE = (
38 (STATUS_CONFIRM, _("Awaiting email confirmation")),
39 (STATUS_REVIEW, _("Ready for review")),
40 (STATUS_REJECTED, _("Rejected")),
41 (STATUS_ACCEPTED, _("Accepted")),
42 (STATUS_COMPLETED, _("Completed")),
43 )
45 status = models.CharField(
46 verbose_name=_("status"),
47 choices=STATUS_TYPE,
48 max_length=20,
49 default="confirm",
50 )
52 MEMBERSHIP_YEAR = "year"
53 MEMBERSHIP_STUDY = "study"
55 MEMBERSHIP_LENGTHS = (
56 (
57 MEMBERSHIP_YEAR,
58 _("One year")
59 + f" -- €{floatformat(settings.MEMBERSHIP_PRICES['year'], 2)}",
60 ),
61 (
62 MEMBERSHIP_STUDY,
63 _("Until graduation")
64 + f" -- €{floatformat(settings.MEMBERSHIP_PRICES['study'], 2)}",
65 ),
66 )
68 length = models.CharField(
69 verbose_name=_("membership length"),
70 choices=MEMBERSHIP_LENGTHS,
71 help_text="Warning: changing this in the admin does not update the contribution.",
72 max_length=20,
73 )
75 MEMBERSHIP_TYPES = [
76 m for m in Membership.MEMBERSHIP_TYPES if m[0] != Membership.HONORARY
77 ]
79 contribution = PaymentAmountField(
80 verbose_name=_("contribution"),
81 validators=[MinValueValidator(settings.MEMBERSHIP_PRICES[MEMBERSHIP_YEAR])],
82 default=settings.MEMBERSHIP_PRICES[MEMBERSHIP_YEAR],
83 blank=False,
84 null=False,
85 )
87 no_references = models.BooleanField(
88 verbose_name=_("no references required"), default=False
89 )
91 membership_type = models.CharField(
92 verbose_name=_("membership type"),
93 choices=MEMBERSHIP_TYPES,
94 max_length=40,
95 default=Membership.MEMBER,
96 )
98 remarks = models.TextField(
99 _("remarks"),
100 blank=True,
101 null=True,
102 )
104 membership = models.ForeignKey(
105 "members.Membership",
106 on_delete=models.SET_NULL,
107 blank=True,
108 null=True,
109 )
111 def save(
112 self, force_insert=False, force_update=False, using=None, update_fields=None
113 ):
114 if self.status not in (self.STATUS_ACCEPTED, self.STATUS_REJECTED):
115 self.updated_at = timezone.now()
117 super().save(force_insert, force_update, using, update_fields)
119 def clean(self):
120 super().clean()
121 errors = {}
123 if self.membership_type == Membership.BENEFACTOR:
124 if self.contribution is None:
125 errors.update(
126 {"contribution": "This field is required for benefactors."}
127 )
128 if self.length != Entry.MEMBERSHIP_YEAR:
129 errors.update(
130 {"length": "Benefactors can only have a one-year memberships."}
131 )
133 if errors:
134 raise ValidationError(errors)
136 def __str__(self):
137 try:
138 return self.registration.__str__()
139 except Registration.DoesNotExist:
140 return self.renewal.__str__()
142 class Meta:
143 verbose_name = _("entry")
144 verbose_name_plural = _("entries")
145 permissions = (
146 ("review_entries", _("Review registration and renewal entries")),
147 )
150class Registration(Entry):
151 """Describes a new registration for the association."""
153 # Payment field is duplicated between Registration and Renewal to allow
154 # distinguishing the two separate relations backwards from Payment to the
155 # two kinds of entries. That way, we can efficiently look the right Payable.
156 payment = models.OneToOneField(
157 "payments.Payment",
158 on_delete=models.SET_NULL,
159 blank=True,
160 null=True,
161 )
163 # ---- Personal information -----
165 username = models.CharField(
166 _("Username"),
167 max_length=64, # This length is lower than Django because of G Suite
168 blank=True,
169 null=True,
170 help_text=_(
171 "Enter value to override the auto-generated username "
172 "(e.g. if it is not unique)"
173 ),
174 validators=[
175 RegexValidator(
176 regex="^[a-zA-Z0-9]{1,64}$",
177 message=_(
178 "Please use 64 characters or fewer. Letters and digits only."
179 ),
180 )
181 ],
182 )
184 first_name = models.CharField(
185 _("First name"),
186 max_length=30,
187 validators=[
188 RegexValidator(
189 regex="^([^/@:;%_]*)$",
190 message=_(
191 "The first name should not contain special characters like '/' or '@'."
192 ),
193 )
194 ],
195 )
197 last_name = models.CharField(
198 _("Last name"),
199 max_length=200,
200 validators=[
201 RegexValidator(
202 regex="^([^/@:;%_]*)$",
203 message=_(
204 "The last name should not contain special characters like '/' or '@'."
205 ),
206 )
207 ],
208 )
210 birthday = models.DateField(
211 verbose_name=_("birthday"),
212 blank=False,
213 )
215 # ---- Contact information -----
217 email = models.EmailField(
218 _("Email address"),
219 blank=False,
220 )
222 phone_number = models.CharField(
223 max_length=20,
224 verbose_name=_("phone number"),
225 validators=[
226 validators.RegexValidator(
227 regex=r"^\+?\d+$",
228 message=_("please enter a valid phone number"),
229 )
230 ],
231 blank=True,
232 null=True,
233 )
235 # ---- University information -----
237 student_number = models.CharField(
238 verbose_name=_("student number"),
239 max_length=8,
240 validators=[
241 validators.RegexValidator(
242 regex=r"([Ss]\d{7}|[EZUezu]\d{6,7})",
243 message=_("enter a valid student- or e/z/u-number."),
244 )
245 ],
246 help_text=_("With prefix. For example: 's5603249'."),
247 blank=True,
248 null=True,
249 )
251 programme = models.CharField(
252 max_length=20,
253 choices=Profile.PROGRAMME_CHOICES,
254 verbose_name=_("study programme"),
255 blank=True,
256 null=True,
257 )
259 starting_year = models.IntegerField(
260 verbose_name=_("starting year"),
261 blank=True,
262 null=True,
263 )
265 # ---- Address information -----
267 address_street = models.CharField(
268 max_length=100,
269 validators=[
270 validators.RegexValidator(
271 regex=r"^.+ \d+.*",
272 message=_("please use the format <street> <number>"),
273 )
274 ],
275 verbose_name=_("street and house number"),
276 blank=False,
277 )
279 address_street2 = models.CharField(
280 max_length=100,
281 verbose_name=_("second address line"),
282 blank=True,
283 null=True,
284 )
286 address_postal_code = models.CharField(
287 max_length=10,
288 verbose_name=_("postal code"),
289 blank=False,
290 )
292 address_city = models.CharField(
293 max_length=40,
294 verbose_name=_("city"),
295 blank=False,
296 )
298 address_country = models.CharField(
299 max_length=2,
300 choices=countries.EUROPE,
301 verbose_name=_("Country"),
302 null=True,
303 )
305 # ---- Opt-ins -----
307 optin_mailinglist = models.BooleanField(
308 verbose_name=_("mailinglist opt-in"), default=False
309 )
311 optin_thabloid = models.BooleanField(
312 verbose_name=_("Thabloid opt-in"), default=True
313 )
315 optin_birthday = models.BooleanField(
316 verbose_name=_("birthday calendar opt-in"), default=False
317 )
319 # ---- Bank account -----
321 direct_debit = models.BooleanField(
322 null=False,
323 blank=False,
324 default=False,
325 help_text=_(
326 "When the registration is accepted and this checkbox is enabled, a "
327 "Thalia Pay payment will be created for this user and the registration "
328 "will be completed immediately. This can only be selected if a bank "
329 "account is added with direct debit authorisation during registration."
330 ),
331 )
333 initials = models.CharField(
334 verbose_name=_("initials"), max_length=20, blank=True, null=True
335 )
337 iban = IBANField(
338 verbose_name=_("IBAN"),
339 include_countries=IBAN_SEPA_COUNTRIES,
340 blank=True,
341 null=True,
342 )
344 bic = BICField(
345 verbose_name=_("BIC"),
346 blank=True,
347 null=True,
348 help_text=_("This field is optional for Dutch bank accounts."),
349 )
351 signature = models.TextField(
352 verbose_name=_("signature"),
353 blank=True,
354 null=True,
355 )
357 def get_full_name(self):
358 full_name = f"{self.first_name} {self.last_name}"
359 return full_name.strip()
361 def _generate_default_username(self) -> str:
362 """Create default username from first and lastname."""
363 username = (self.first_name[0] + self.last_name).lower()
364 username = "".join(c for c in username if c.isalpha())
365 username = "".join(
366 c
367 for c in unicodedata.normalize("NFKD", username)
368 if c in string.ascii_letters
369 ).lower()
371 # Limit length to 150 characters since Django doesn't support longer
372 if len(username) > 150:
373 username = username[:150]
375 return username.lower()
377 def get_username(self):
378 """Get the automatic or overridden username."""
379 return self.username or self._generate_default_username()
381 def check_user_is_unique(self):
382 """Check that the username and email are unique."""
383 return not (
384 get_user_model()
385 .objects.filter(
386 models.Q(email=self.email) | models.Q(username=self.get_username())
387 )
388 .exists()
389 )
391 def clean(self):
392 super().clean()
393 errors = {}
395 if (
396 get_user_model().objects.filter(email=self.email).exists()
397 or Registration.objects.filter(email=self.email)
398 .exclude(pk=self.pk)
399 .exists()
400 ):
401 errors.update(
402 {
403 "email": _(
404 "A user with that email address already exists. "
405 "Login using the existing account and renew the "
406 "membership by visiting the account settings."
407 )
408 }
409 )
411 if self.student_number is not None:
412 self.student_number = self.student_number.lower()
413 if (
414 Profile.objects.filter(student_number=self.student_number).exists()
415 or Registration.objects.filter(student_number=self.student_number)
416 .exclude(pk=self.pk)
417 .exists()
418 ):
419 errors.update(
420 {
421 "student_number": _(
422 "A user with that student number already exists. "
423 "Login using the existing account and renew the "
424 "membership by visiting the account settings."
425 )
426 }
427 )
428 elif (
429 self.student_number is None
430 and self.membership_type != Membership.BENEFACTOR
431 ):
432 errors.update({"student_number": _("This field is required.")})
434 if self.username is not None and (
435 get_user_model().objects.filter(username=self.username).exists()
436 or Registration.objects.filter(username=self.username)
437 .exclude(pk=self.pk)
438 .exists()
439 ):
440 errors.update({"username": _("A user with that username already exists.")})
442 if self.starting_year is None and self.membership_type != Membership.BENEFACTOR:
443 errors.update({"starting_year": _("This field is required.")})
445 if self.programme is None and self.membership_type != Membership.BENEFACTOR:
446 errors.update({"programme": _("This field is required.")})
448 if self.birthday and self.birthday > timezone.now().date():
449 errors.update({"birthday": _("A birthday cannot be in the future.")})
451 if self.direct_debit:
452 if not self.iban:
453 errors["iban"] = _(
454 "This field is required to add a bank account mandate for Thalia Pay."
455 )
457 if not self.initials:
458 errors["initials"] = _(
459 "This field is required to add a bank account mandate for Thalia Pay."
460 )
462 if not self.signature:
463 errors["signature"] = _(
464 "This field is required to add a bank account mandate for Thalia Pay."
465 )
467 if self.iban and self.iban[0:2] != "NL" and not self.bic:
468 errors["bic"] = _("This field is required for foreign bank accounts.")
470 if errors:
471 raise ValidationError(errors)
473 def __str__(self):
474 return f"{self.first_name} {self.last_name} ({self.email})"
476 class Meta:
477 verbose_name = _("registration")
478 verbose_name_plural = _("registrations")
481class Renewal(Entry):
482 """Describes a renewal for the association membership."""
484 # Payment field is duplicated between Registration and Renewal to allow
485 # distinguishing the two separate relations backwards from Payment to the
486 # two kinds of entries. That way, we can efficiently look the right Payable.
487 payment = models.OneToOneField(
488 "payments.Payment",
489 on_delete=models.SET_NULL,
490 blank=True,
491 null=True,
492 )
494 member = models.ForeignKey(
495 "members.Member",
496 on_delete=models.CASCADE,
497 verbose_name=_("member"),
498 blank=False,
499 null=False,
500 )
502 def save(
503 self, force_insert=False, force_update=False, using=None, update_fields=None
504 ):
505 if self.pk is None:
506 self.status = Entry.STATUS_REVIEW
507 super().save(force_insert, force_update, using, update_fields)
509 def clean(self):
510 super().clean()
511 errors = {}
513 if (
514 Renewal.objects.filter(member=self.member, status=Entry.STATUS_REVIEW)
515 .exclude(pk=self.pk)
516 .exists()
517 ):
518 raise ValidationError(
519 _("You already have a renewal request queued for review.")
520 )
522 self.member.refresh_from_db()
523 current_membership = self.member.current_membership
524 # Invalid form for study and honorary members
525 if current_membership is not None and current_membership.until is None:
526 errors.update(
527 {
528 "length": _("You currently have an active membership."),
529 "membership_type": _("You currently have an active membership."),
530 }
531 )
533 latest_membership = self.member.latest_membership
534 hide_year_choice = not (
535 latest_membership is not None
536 and latest_membership.until is not None
537 and (latest_membership.until - timezone.now().date()).days <= 31
538 )
540 if self.length == Entry.MEMBERSHIP_YEAR and hide_year_choice:
541 errors.update(
542 {"length": _("You cannot renew your membership at this moment.")}
543 )
545 if errors:
546 raise ValidationError(errors)
548 def __str__(self):
549 return f"{self.member.first_name} {self.member.last_name} ({self.member.email})"
551 class Meta:
552 verbose_name = _("renewal")
553 verbose_name_plural = _("renewals")
556class Reference(models.Model):
557 """Describes a reference of a member for a potential member."""
559 member = models.ForeignKey(
560 "members.Member",
561 on_delete=models.CASCADE,
562 verbose_name=_("member"),
563 blank=False,
564 null=False,
565 )
567 entry = models.ForeignKey(
568 "registrations.Entry",
569 on_delete=models.CASCADE,
570 verbose_name=_("entry"),
571 blank=False,
572 null=False,
573 )
575 def __str__(self):
576 return f"Reference from {self.member} for {self.entry}"
578 class Meta:
579 unique_together = ("member", "entry")