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

1import string 

2import unicodedata 

3import uuid 

4 

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 _ 

14 

15from localflavor.generic.countries.sepa import IBAN_SEPA_COUNTRIES 

16from localflavor.generic.models import BICField, IBANField 

17 

18from members.models import Membership, Profile 

19from payments.models import PaymentAmountField 

20from utils import countries 

21 

22 

23class Entry(models.Model): 

24 """Describes a registration entry.""" 

25 

26 id = models.UUIDField(primary_key=True, default=uuid.uuid4, editable=False) 

27 

28 created_at = models.DateTimeField(_("created at"), default=timezone.now) 

29 updated_at = models.DateTimeField(_("updated at"), default=timezone.now) 

30 

31 STATUS_CONFIRM = "confirm" 

32 STATUS_REVIEW = "review" 

33 STATUS_REJECTED = "rejected" 

34 STATUS_ACCEPTED = "accepted" 

35 STATUS_COMPLETED = "completed" 

36 

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 ) 

44 

45 status = models.CharField( 

46 verbose_name=_("status"), 

47 choices=STATUS_TYPE, 

48 max_length=20, 

49 default="confirm", 

50 ) 

51 

52 MEMBERSHIP_YEAR = "year" 

53 MEMBERSHIP_STUDY = "study" 

54 

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 ) 

67 

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 ) 

74 

75 MEMBERSHIP_TYPES = [ 

76 m for m in Membership.MEMBERSHIP_TYPES if m[0] != Membership.HONORARY 

77 ] 

78 

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 ) 

86 

87 no_references = models.BooleanField( 

88 verbose_name=_("no references required"), default=False 

89 ) 

90 

91 membership_type = models.CharField( 

92 verbose_name=_("membership type"), 

93 choices=MEMBERSHIP_TYPES, 

94 max_length=40, 

95 default=Membership.MEMBER, 

96 ) 

97 

98 remarks = models.TextField( 

99 _("remarks"), 

100 blank=True, 

101 null=True, 

102 ) 

103 

104 membership = models.ForeignKey( 

105 "members.Membership", 

106 on_delete=models.SET_NULL, 

107 blank=True, 

108 null=True, 

109 ) 

110 

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() 

116 

117 super().save(force_insert, force_update, using, update_fields) 

118 

119 def clean(self): 

120 super().clean() 

121 errors = {} 

122 

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 ) 

132 

133 if errors: 

134 raise ValidationError(errors) 

135 

136 def __str__(self): 

137 try: 

138 return self.registration.__str__() 

139 except Registration.DoesNotExist: 

140 return self.renewal.__str__() 

141 

142 class Meta: 

143 verbose_name = _("entry") 

144 verbose_name_plural = _("entries") 

145 permissions = ( 

146 ("review_entries", _("Review registration and renewal entries")), 

147 ) 

148 

149 

150class Registration(Entry): 

151 """Describes a new registration for the association.""" 

152 

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 ) 

162 

163 # ---- Personal information ----- 

164 

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 ) 

183 

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 ) 

196 

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 ) 

209 

210 birthday = models.DateField( 

211 verbose_name=_("birthday"), 

212 blank=False, 

213 ) 

214 

215 # ---- Contact information ----- 

216 

217 email = models.EmailField( 

218 _("Email address"), 

219 blank=False, 

220 ) 

221 

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 ) 

234 

235 # ---- University information ----- 

236 

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 ) 

250 

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 ) 

258 

259 starting_year = models.IntegerField( 

260 verbose_name=_("starting year"), 

261 blank=True, 

262 null=True, 

263 ) 

264 

265 # ---- Address information ----- 

266 

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 ) 

278 

279 address_street2 = models.CharField( 

280 max_length=100, 

281 verbose_name=_("second address line"), 

282 blank=True, 

283 null=True, 

284 ) 

285 

286 address_postal_code = models.CharField( 

287 max_length=10, 

288 verbose_name=_("postal code"), 

289 blank=False, 

290 ) 

291 

292 address_city = models.CharField( 

293 max_length=40, 

294 verbose_name=_("city"), 

295 blank=False, 

296 ) 

297 

298 address_country = models.CharField( 

299 max_length=2, 

300 choices=countries.EUROPE, 

301 verbose_name=_("Country"), 

302 null=True, 

303 ) 

304 

305 # ---- Opt-ins ----- 

306 

307 optin_mailinglist = models.BooleanField( 

308 verbose_name=_("mailinglist opt-in"), default=False 

309 ) 

310 

311 optin_thabloid = models.BooleanField( 

312 verbose_name=_("Thabloid opt-in"), default=True 

313 ) 

314 

315 optin_birthday = models.BooleanField( 

316 verbose_name=_("birthday calendar opt-in"), default=False 

317 ) 

318 

319 # ---- Bank account ----- 

320 

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 ) 

332 

333 initials = models.CharField( 

334 verbose_name=_("initials"), max_length=20, blank=True, null=True 

335 ) 

336 

337 iban = IBANField( 

338 verbose_name=_("IBAN"), 

339 include_countries=IBAN_SEPA_COUNTRIES, 

340 blank=True, 

341 null=True, 

342 ) 

343 

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 ) 

350 

351 signature = models.TextField( 

352 verbose_name=_("signature"), 

353 blank=True, 

354 null=True, 

355 ) 

356 

357 def get_full_name(self): 

358 full_name = f"{self.first_name} {self.last_name}" 

359 return full_name.strip() 

360 

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() 

370 

371 # Limit length to 150 characters since Django doesn't support longer 

372 if len(username) > 150: 

373 username = username[:150] 

374 

375 return username.lower() 

376 

377 def get_username(self): 

378 """Get the automatic or overridden username.""" 

379 return self.username or self._generate_default_username() 

380 

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 ) 

390 

391 def clean(self): 

392 super().clean() 

393 errors = {} 

394 

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 ) 

410 

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

433 

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

441 

442 if self.starting_year is None and self.membership_type != Membership.BENEFACTOR: 

443 errors.update({"starting_year": _("This field is required.")}) 

444 

445 if self.programme is None and self.membership_type != Membership.BENEFACTOR: 

446 errors.update({"programme": _("This field is required.")}) 

447 

448 if self.birthday and self.birthday > timezone.now().date(): 

449 errors.update({"birthday": _("A birthday cannot be in the future.")}) 

450 

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 ) 

456 

457 if not self.initials: 

458 errors["initials"] = _( 

459 "This field is required to add a bank account mandate for Thalia Pay." 

460 ) 

461 

462 if not self.signature: 

463 errors["signature"] = _( 

464 "This field is required to add a bank account mandate for Thalia Pay." 

465 ) 

466 

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.") 

469 

470 if errors: 

471 raise ValidationError(errors) 

472 

473 def __str__(self): 

474 return f"{self.first_name} {self.last_name} ({self.email})" 

475 

476 class Meta: 

477 verbose_name = _("registration") 

478 verbose_name_plural = _("registrations") 

479 

480 

481class Renewal(Entry): 

482 """Describes a renewal for the association membership.""" 

483 

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 ) 

493 

494 member = models.ForeignKey( 

495 "members.Member", 

496 on_delete=models.CASCADE, 

497 verbose_name=_("member"), 

498 blank=False, 

499 null=False, 

500 ) 

501 

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) 

508 

509 def clean(self): 

510 super().clean() 

511 errors = {} 

512 

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 ) 

521 

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 ) 

532 

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 ) 

539 

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 ) 

544 

545 if errors: 

546 raise ValidationError(errors) 

547 

548 def __str__(self): 

549 return f"{self.member.first_name} {self.member.last_name} ({self.member.email})" 

550 

551 class Meta: 

552 verbose_name = _("renewal") 

553 verbose_name_plural = _("renewals") 

554 

555 

556class Reference(models.Model): 

557 """Describes a reference of a member for a potential member.""" 

558 

559 member = models.ForeignKey( 

560 "members.Member", 

561 on_delete=models.CASCADE, 

562 verbose_name=_("member"), 

563 blank=False, 

564 null=False, 

565 ) 

566 

567 entry = models.ForeignKey( 

568 "registrations.Entry", 

569 on_delete=models.CASCADE, 

570 verbose_name=_("entry"), 

571 blank=False, 

572 null=False, 

573 ) 

574 

575 def __str__(self): 

576 return f"Reference from {self.member} for {self.entry}" 

577 

578 class Meta: 

579 unique_together = ("member", "entry")