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

1"""The services defined by the registrations package.""" 

2 

3import secrets 

4 

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 

11 

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 

20 

21 

22def confirm_registration(registration: Registration) -> None: 

23 """Confirm a registration. 

24 

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

30 

31 registration.status = registration.STATUS_REVIEW 

32 registration.save() 

33 

34 if ( 

35 registration.membership_type == Membership.BENEFACTOR 

36 and not registration.no_references 

37 ): 

38 emails.send_references_information_message(registration) 

39 

40 emails.send_new_registration_board_message(registration) 

41 

42 

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

48 

49 registration.status = registration.STATUS_REJECTED 

50 registration.save() 

51 

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 ) 

61 

62 emails.send_registration_rejected_message(registration) 

63 

64 

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

75 

76 with transaction.atomic(): 

77 if registration.payment: 

78 registration.payment.delete() 

79 

80 registration.payment = None 

81 registration.status = registration.STATUS_REVIEW 

82 registration.save() 

83 

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 ) 

94 

95 

96def accept_registration(registration: Registration, actor: Member) -> None: 

97 """Accept a registration. 

98 

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. 

101 

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

107 

108 with transaction.atomic(): 

109 if not registration.check_user_is_unique(): 

110 raise ValueError("Username or email is not unique") 

111 

112 registration.status = registration.STATUS_ACCEPTED 

113 registration.save() 

114 

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 ) 

124 

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) 

130 

131 if not registration.direct_debit: 

132 # Inform the user that they need to pay. 

133 emails.send_registration_accepted_message(registration) 

134 

135 

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

144 

145 if renewal.payment: 

146 renewal.payment.delete() 

147 

148 renewal.payment = None 

149 renewal.status = renewal.STATUS_REVIEW 

150 renewal.save() 

151 

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 ) 

162 

163 

164def complete_registration(registration: Registration) -> None: 

165 """Complete a registration, creating a Member, Profile and Membership. 

166 

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. 

169 

170 If anything goes wrong, database changes will be rolled back. 

171 

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

179 

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

189 

190 if not registration.payment: 

191 # Create a Thalia Pay payment. 

192 create_payment(registration, member, Payment.TPAY) 

193 registration.refresh_from_db() 

194 

195 

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

201 

202 renewal.status = renewal.STATUS_REJECTED 

203 renewal.save() 

204 

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 ) 

214 

215 emails.send_renewal_rejected_message(renewal) 

216 

217 

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

222 

223 renewal.status = renewal.STATUS_ACCEPTED 

224 renewal.save() 

225 

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 ) 

235 

236 emails.send_renewal_accepted_message(renewal) 

237 

238 

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

246 

247 member: Member = renewal.member 

248 member.refresh_from_db() 

249 

250 since = timezone.now().date() 

251 lecture_year = datetime_to_lectureyear(since) 

252 if since.month == 8: 

253 lecture_year += 1 

254 

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 

273 

274 renewal.membership = Membership.objects.create( 

275 type=renewal.membership_type, 

276 user=member, 

277 since=since, 

278 until=until, 

279 ) 

280 

281 renewal.status = renewal.STATUS_COMPLETED 

282 renewal.save() 

283 

284 emails.send_renewal_complete_message(renewal) 

285 

286 

287_PASSWORD_CHARS = "abcdefghjkmnpqrstuvwxyzABCDEFGHJKLMNPQRSTUVWXYZ23456789" 

288 

289 

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

294 

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

298 

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 ) 

307 

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

312 

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 ) 

330 

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 ) 

345 

346 # Send welcome message to new member 

347 send_welcome_message(user, password) 

348 

349 return Member.objects.get(pk=user.pk) 

350 

351 

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 

373 

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 ) 

381 

382 

383def execute_data_minimisation(dry_run=False): 

384 """Delete completed or rejected registrations that were modified at least 31 days ago. 

385 

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 ) 

398 

399 if dry_run: 

400 return registrations.count() + renewals.count() # pragma: no cover 

401 

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

407 

408 return registrations.delete()[0] + renewals.delete()[0]