Coverage for website/registrations/services.py: 100.00%
161 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
1"""The services defined by the registrations package."""
3import secrets
5from django.contrib.admin.models import CHANGE, LogEntry
6from django.contrib.admin.options import get_content_type_for_model
7from django.contrib.auth import get_user_model
8from django.db import transaction
9from django.db.models import Q, Value
10from django.utils import timezone
12from members.emails import send_welcome_message
13from members.models import Member, Membership, Profile
14from payments.models import BankAccount, Payment, PaymentUser
15from payments.services import create_payment
16from registrations import emails
17from registrations.models import Entry, Registration, Renewal
18from thabloid.models.thabloid_user import ThabloidUser
19from utils.snippets import datetime_to_lectureyear
22def confirm_registration(registration: Registration) -> None:
23 """Confirm a registration.
25 This happens when the user has verified their email address.
26 """
27 registration.refresh_from_db()
28 if registration.status != registration.STATUS_CONFIRM:
29 raise ValueError("Registration is already confirmed.")
31 registration.status = registration.STATUS_REVIEW
32 registration.save()
34 if (
35 registration.membership_type == Membership.BENEFACTOR
36 and not registration.no_references
37 ):
38 emails.send_references_information_message(registration)
40 emails.send_new_registration_board_message(registration)
43def reject_registration(registration: Registration, actor: Member) -> None:
44 """Reject a registration."""
45 registration.refresh_from_db()
46 if registration.status != registration.STATUS_REVIEW:
47 raise ValueError("Registration is not in review.")
49 registration.status = registration.STATUS_REJECTED
50 registration.save()
52 # Log that the `actor` changed the status.
53 LogEntry.objects.log_action(
54 user_id=actor.id,
55 content_type_id=get_content_type_for_model(registration).pk,
56 object_id=registration.pk,
57 object_repr=str(registration),
58 action_flag=CHANGE,
59 change_message="Changed status to rejected",
60 )
62 emails.send_registration_rejected_message(registration)
65def revert_registration(
66 registration: Registration, actor: Member | None = None
67) -> None:
68 """Undo the review of a registration."""
69 registration.refresh_from_db()
70 if registration.status not in (
71 registration.STATUS_ACCEPTED,
72 registration.STATUS_REJECTED,
73 ):
74 raise ValueError("Registration is not accepted or rejected.")
76 with transaction.atomic():
77 if registration.payment:
78 registration.payment.delete()
80 registration.payment = None
81 registration.status = registration.STATUS_REVIEW
82 registration.save()
84 if actor is not None: # pragma: no cover
85 # Log that the `actor` changed the status.
86 LogEntry.objects.log_action(
87 user_id=actor.id,
88 content_type_id=get_content_type_for_model(registration).pk,
89 object_id=registration.pk,
90 object_repr=str(registration),
91 action_flag=CHANGE,
92 change_message="Reverted status to review",
93 )
96def accept_registration(registration: Registration, actor: Member) -> None:
97 """Accept a registration.
99 If the registration wants to pay with direct debit, this will also
100 complete the registration, and then pay for it with Thalia Pay.
102 Otherwise, an email will be sent informing the user that they need to pay.
103 """
104 registration.refresh_from_db()
105 if registration.status != registration.STATUS_REVIEW:
106 raise ValueError("Registration is not in review.")
108 with transaction.atomic():
109 if not registration.check_user_is_unique():
110 raise ValueError("Username or email is not unique")
112 registration.status = registration.STATUS_ACCEPTED
113 registration.save()
115 # Log that the `actor` changed the status.
116 LogEntry.objects.log_action(
117 user_id=actor.id,
118 content_type_id=get_content_type_for_model(registration).pk,
119 object_id=registration.pk,
120 object_repr=str(registration),
121 action_flag=CHANGE,
122 change_message="Changed status to approved",
123 )
125 # Complete and pay the registration with Thalia Pay if possible.
126 if registration.direct_debit:
127 # If this raises, propagate the exception and roll back the transaction.
128 # The 'accepting' of the registration will be rolled back as well.
129 complete_registration(registration)
131 if not registration.direct_debit:
132 # Inform the user that they need to pay.
133 emails.send_registration_accepted_message(registration)
136def revert_renewal(renewal: Renewal, actor: Member | None = None) -> None:
137 """Undo the review of a registration."""
138 renewal.refresh_from_db()
139 if renewal.status not in (
140 renewal.STATUS_ACCEPTED,
141 renewal.STATUS_REJECTED,
142 ):
143 raise ValueError("Registration is not accepted or rejected.")
145 if renewal.payment:
146 renewal.payment.delete()
148 renewal.payment = None
149 renewal.status = renewal.STATUS_REVIEW
150 renewal.save()
152 if actor is not None: # pragma: no cover
153 # Log that the `actor` changed the status.
154 LogEntry.objects.log_action(
155 user_id=actor.id,
156 content_type_id=get_content_type_for_model(renewal).pk,
157 object_id=renewal.pk,
158 object_repr=str(renewal),
159 action_flag=CHANGE,
160 change_message="Reverted status to review",
161 )
164def complete_registration(registration: Registration) -> None:
165 """Complete a registration, creating a Member, Profile and Membership.
167 This will create a Thalia Pay payment after completing the registration, if
168 the registration is not yet paid and direct debit is enabled.
170 If anything goes wrong, database changes will be rolled back.
172 If direct debit is not enabled, the registration must already be paid for.
173 """
174 registration.refresh_from_db()
175 if registration.status != registration.STATUS_ACCEPTED:
176 raise ValueError("Registration is not accepted.")
177 elif registration.payment is None and not registration.direct_debit:
178 raise ValueError("Registration has not been paid for.")
180 # If anything goes wrong, the changes to the database will be rolled back.
181 # Specifically, if an exception is raised when creating a Thalia Pay payment,
182 # the registration will not be completed, so it can be handled properly.
183 with transaction.atomic():
184 member = _create_member(registration)
185 membership = _create_membership_from_registration(registration, member)
186 registration.membership = membership
187 registration.status = registration.STATUS_COMPLETED
188 registration.save()
190 if not registration.payment:
191 # Create a Thalia Pay payment.
192 create_payment(registration, member, Payment.TPAY)
193 registration.refresh_from_db()
196def reject_renewal(renewal: Renewal, actor: Member):
197 """Reject a renewal."""
198 renewal.refresh_from_db()
199 if renewal.status != renewal.STATUS_REVIEW:
200 raise ValueError("Renewal is not in review.")
202 renewal.status = renewal.STATUS_REJECTED
203 renewal.save()
205 # Log that the `actor` changed the status.
206 LogEntry.objects.log_action(
207 user_id=actor.id,
208 content_type_id=get_content_type_for_model(renewal).pk,
209 object_id=renewal.pk,
210 object_repr=str(renewal),
211 action_flag=CHANGE,
212 change_message="Changed status to rejected",
213 )
215 emails.send_renewal_rejected_message(renewal)
218def accept_renewal(renewal: Renewal, actor: Member):
219 renewal.refresh_from_db()
220 if renewal.status != renewal.STATUS_REVIEW:
221 raise ValueError("Renewal is not in review.")
223 renewal.status = renewal.STATUS_ACCEPTED
224 renewal.save()
226 # Log that the `actor` changed the status.
227 LogEntry.objects.log_action(
228 user_id=actor.id,
229 content_type_id=get_content_type_for_model(renewal).pk,
230 object_id=renewal.pk,
231 object_repr=str(renewal),
232 action_flag=CHANGE,
233 change_message="Changed status to approved",
234 )
236 emails.send_renewal_accepted_message(renewal)
239def complete_renewal(renewal: Renewal):
240 """Complete a renewal, prolonging a Membership or creating a new one."""
241 renewal.refresh_from_db()
242 if renewal.status != renewal.STATUS_ACCEPTED:
243 raise ValueError("Renewal is not accepted.")
244 elif renewal.payment is None:
245 raise ValueError("Registration has not been paid for.")
247 member: Member = renewal.member
248 member.refresh_from_db()
250 since = timezone.now().date()
251 lecture_year = datetime_to_lectureyear(since)
252 if since.month == 8:
253 lecture_year += 1
255 latest_membership = member.latest_membership
256 current_membership = member.current_membership
257 if (
258 latest_membership
259 and latest_membership.study_long
260 and renewal.membership_type != Membership.BENEFACTOR
261 ) or (current_membership and current_membership.until is None):
262 raise ValueError("This member already has a never ending membership.")
263 until = timezone.datetime(year=lecture_year + 1, month=9, day=1).date()
264 with transaction.atomic():
265 if renewal.length == Renewal.MEMBERSHIP_STUDY:
266 latest_membership.study_long = True
267 latest_membership.until = until
268 latest_membership.save()
269 renewal.membership = latest_membership
270 else:
271 if current_membership:
272 since = current_membership.until
274 renewal.membership = Membership.objects.create(
275 type=renewal.membership_type,
276 user=member,
277 since=since,
278 until=until,
279 )
281 renewal.status = renewal.STATUS_COMPLETED
282 renewal.save()
284 emails.send_renewal_complete_message(renewal)
287_PASSWORD_CHARS = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789"
290def _create_member(registration: Registration) -> Member:
291 """Create a member and profile from a Registration."""
292 # Generate random password for user that we can send to the new user.
293 password = "".join(secrets.choice(_PASSWORD_CHARS) for _ in range(15))
295 # Make sure the username and email are unique
296 if not registration.check_user_is_unique():
297 raise ValueError("Username or email is not unique.")
299 # Create user.
300 user = get_user_model().objects.create_user(
301 username=registration.get_username(),
302 email=registration.email,
303 password=password,
304 first_name=registration.first_name,
305 last_name=registration.last_name,
306 )
308 if registration.optin_thabloid:
309 ThabloidUser.objects.get(pk=user.pk).allow_thabloid()
310 else:
311 ThabloidUser.objects.get(pk=user.pk).disallow_thabloid()
313 # Add profile to created user.
314 Profile.objects.create(
315 user=user,
316 programme=registration.programme,
317 student_number=registration.student_number,
318 starting_year=registration.starting_year,
319 address_street=registration.address_street,
320 address_street2=registration.address_street2,
321 address_postal_code=registration.address_postal_code,
322 address_city=registration.address_city,
323 address_country=registration.address_country,
324 phone_number=registration.phone_number,
325 birthday=registration.birthday,
326 show_birthday=registration.optin_birthday,
327 receive_optin=registration.optin_mailinglist,
328 receive_oldmembers=registration.membership_type == Membership.MEMBER,
329 )
331 if registration.direct_debit:
332 # Add a bank account.
333 payment_user = PaymentUser.objects.get(pk=user.pk)
334 payment_user.allow_tpay()
335 BankAccount.objects.create(
336 owner=payment_user,
337 iban=registration.iban,
338 bic=registration.bic,
339 initials=registration.initials,
340 last_name=registration.last_name,
341 signature=registration.signature,
342 mandate_no=f"{user.pk}-{1}",
343 valid_from=registration.created_at,
344 )
346 # Send welcome message to new member
347 send_welcome_message(user, password)
349 return Member.objects.get(pk=user.pk)
352def _create_membership_from_registration(
353 registration: Registration, member: Member
354) -> Membership:
355 """Create a membership from a Registration."""
356 since = timezone.now().date()
357 if (
358 registration.length == Registration.MEMBERSHIP_YEAR
359 or registration.length == Registration.MEMBERSHIP_STUDY
360 ) and not registration.membership_type == Membership.HONORARY:
361 lecture_year = datetime_to_lectureyear(since)
362 if since.month == 8:
363 # Memberships created in august are for the next lecture year,
364 # but can start already in august. This allows first year students
365 # to use their membership already in the introduction week.
366 lecture_year += 1
367 until = timezone.datetime(year=lecture_year + 1, month=9, day=1).date()
368 else:
369 until = None
370 study_long = False
371 if registration.length == Registration.MEMBERSHIP_STUDY:
372 study_long = True
374 return Membership.objects.create(
375 user=member,
376 since=since,
377 until=until,
378 study_long=study_long,
379 type=registration.membership_type,
380 )
383def execute_data_minimisation(dry_run=False):
384 """Delete completed or rejected registrations that were modified at least 31 days ago.
386 :param dry_run: does not really remove data if True
387 :return: number of removed objects.
388 """
389 deletion_period = timezone.now() - timezone.timedelta(days=31)
390 registrations = Registration.objects.filter(
391 Q(status=Entry.STATUS_COMPLETED) | Q(status=Entry.STATUS_REJECTED),
392 updated_at__lt=deletion_period,
393 )
394 renewals = Renewal.objects.filter(
395 Q(status=Entry.STATUS_COMPLETED) | Q(status=Entry.STATUS_REJECTED),
396 updated_at__lt=deletion_period,
397 )
399 if dry_run:
400 return registrations.count() + renewals.count() # pragma: no cover
402 # Mark that this deletion is for data minimisation so that it can be recognized
403 # in any post_delete signal handlers. This is used to prevent the deletion of
404 # Moneybird invoices.
405 registrations = registrations.annotate(__deleting_for_dataminimisation=Value(True))
406 renewals = renewals.annotate(__deleting_for_dataminimisation=Value(True))
408 return registrations.delete()[0] + renewals.delete()[0]