Coverage for website/events/models/event.py: 80.50%

185 statements  

« prev     ^ index     » next       coverage.py v7.6.12, created at 2026-06-21 23:59 +0000

1import uuid 

2 

3from django.conf import settings 

4from django.core import validators 

5from django.core.exceptions import ObjectDoesNotExist, ValidationError 

6from django.db import models, router 

7from django.db.models import Count, Q 

8from django.db.models.deletion import Collector 

9from django.urls import reverse 

10from django.utils import timezone 

11from django.utils.text import format_lazy 

12from django.utils.translation import gettext_lazy as _ 

13 

14from queryable_properties.managers import QueryablePropertiesManager 

15from queryable_properties.properties import AggregateProperty 

16from tinymce.models import HTMLField 

17 

18from events.models import status 

19from events.models.categories import EVENT_CATEGORIES 

20from payments.models import PaymentAmountField 

21 

22 

23class Event(models.Model): 

24 """Describes an event.""" 

25 

26 objects = QueryablePropertiesManager() 

27 

28 DEFAULT_NO_REGISTRATION_MESSAGE = _("No registration required") 

29 

30 title = models.CharField(_("title"), max_length=100) 

31 

32 slug = models.SlugField( 

33 verbose_name=_("slug"), 

34 help_text=_( 

35 "A short name for the event, used in the URL. For example: thalia-weekend-2023. " 

36 "Note that the slug must be unique." 

37 ), 

38 unique=True, 

39 blank=True, 

40 null=True, 

41 ) 

42 

43 description = HTMLField( 

44 _("description"), 

45 ) 

46 

47 caption = models.TextField( 

48 _("caption"), 

49 max_length=500, 

50 null=False, 

51 blank=False, 

52 help_text=_( 

53 "A short text of max 500 characters for promotion and the newsletter." 

54 ), 

55 ) 

56 

57 start = models.DateTimeField(_("start time")) 

58 

59 end = models.DateTimeField(_("end time")) 

60 

61 organisers = models.ManyToManyField( 

62 "activemembers.MemberGroup", 

63 verbose_name=_("organisers"), 

64 related_name=_("event_organiser"), 

65 ) 

66 

67 category = models.CharField( 

68 max_length=40, 

69 choices=EVENT_CATEGORIES, 

70 verbose_name=_("category"), 

71 help_text=_( 

72 "Alumni: Events organised for alumni, " 

73 "Education: Education focused events, " 

74 "Career: Career focused events, " 

75 "Leisure: borrels, parties, game activities etc., " 

76 "Association Affairs: general meetings or " 

77 "any other board related events, " 

78 "Other: anything else." 

79 ), 

80 ) 

81 

82 registration_start = models.DateTimeField( 

83 _("registration start"), 

84 null=True, 

85 blank=True, 

86 help_text=_( 

87 "If you set a registration period registration will be " 

88 "required. If you don't set one, registration won't be " 

89 "required. Prefer times when people don't have lectures, " 

90 "e.g. 12:30 instead of 13:37." 

91 ), 

92 ) 

93 

94 registration_end = models.DateTimeField( 

95 _("registration end"), 

96 null=True, 

97 blank=True, 

98 help_text=_( 

99 "If you set a registration period registration will be " 

100 "required. If you don't set one, registration won't be " 

101 "required." 

102 ), 

103 ) 

104 

105 update_deadline = models.DateTimeField( 

106 _("registration update deadline"), 

107 null=True, 

108 blank=True, 

109 help_text=_( 

110 "Deadline for participants to update their registration. " 

111 "Updating is always allowed until registration closes, " 

112 "so this field can only be used to extend this.", 

113 ), 

114 ) 

115 

116 cancel_deadline = models.DateTimeField(_("cancel deadline"), null=True, blank=True) 

117 

118 send_cancel_email = models.BooleanField( 

119 _("send cancellation notifications"), 

120 default=True, 

121 help_text=_( 

122 "Send an email to the organising party when a member " 

123 "cancels their registration after the deadline." 

124 ), 

125 ) 

126 

127 registration_without_membership = models.BooleanField( 

128 _("registration without membership"), 

129 default=False, 

130 help_text=_( 

131 "Users without a currently active membership (such as past members) " 

132 "are allowed to register for this event. This is useful for " 

133 "events aimed at alumni, for example." 

134 ), 

135 ) 

136 

137 optional_registrations = models.BooleanField( 

138 _("allow optional registrations"), 

139 default=True, 

140 help_text=_( 

141 "Participants can indicate their optional presence, even though " 

142 "registration is not actually required. This ignores registration " 

143 "start and end time or cancellation deadlines, optional " 

144 "registration will be enabled directly after publishing until the " 

145 "end of the event." 

146 ), 

147 ) 

148 

149 location = models.CharField( 

150 _("location"), 

151 max_length=255, 

152 ) 

153 

154 map_location = models.CharField( 

155 _("location for minimap"), 

156 max_length=255, 

157 help_text=_( 

158 "Location of Huygens: Heyendaalseweg 135, Nijmegen. " 

159 "Location of Mercator 1: Toernooiveld 212, Nijmegen. " 

160 "Use the input 'discord' or 'online' for special placeholders. " 

161 "Not shown as text!!" 

162 ), 

163 ) 

164 

165 show_map_location = models.BooleanField( 

166 _("show url for location"), 

167 default=True, 

168 ) 

169 

170 price = PaymentAmountField( 

171 verbose_name=_("price"), 

172 allow_zero=True, 

173 default=0, 

174 validators=[validators.MinValueValidator(0)], 

175 ) 

176 

177 fine = PaymentAmountField( 

178 verbose_name=_("fine"), 

179 allow_zero=True, 

180 default=0, 

181 # Minimum fine is checked in this model's clean(), as it is only for 

182 # events that require registration. 

183 help_text=_("Fine if participant does not show up (at least €5)."), 

184 validators=[validators.MinValueValidator(0)], 

185 ) 

186 

187 max_participants = models.PositiveSmallIntegerField( 

188 _("maximum number of participants"), 

189 blank=True, 

190 null=True, 

191 ) 

192 

193 no_registration_message = models.CharField( 

194 _("message when there is no registration"), 

195 max_length=200, 

196 blank=True, 

197 null=True, 

198 help_text=( 

199 format_lazy( 

200 "{} {}. {}", 

201 _("Default:"), 

202 DEFAULT_NO_REGISTRATION_MESSAGE, 

203 _( 

204 'This field accepts HTML tags as well, e.g. links with &lta href="https://example.com" target="_blank"&gthttps://example.com&lt/a&gt' 

205 ), 

206 ) 

207 ), 

208 ) 

209 

210 published = models.BooleanField(_("published"), default=False) 

211 

212 documents = models.ManyToManyField( 

213 "documents.Document", 

214 verbose_name=_("documents"), 

215 blank=True, 

216 ) 

217 

218 tpay_allowed = models.BooleanField(_("Allow Thalia Pay"), default=True) 

219 

220 mark_present_url_token = models.UUIDField( 

221 unique=True, default=uuid.uuid4, editable=False 

222 ) 

223 

224 @property 

225 def mark_present_url(self): 

226 """Return a url that a user can use to mark themselves present.""" 

227 return settings.BASE_URL + reverse( 

228 "events:mark-present", 

229 kwargs={ 

230 "pk": self.pk, 

231 "token": self.mark_present_url_token, 

232 }, 

233 ) 

234 

235 @property 

236 def cancel_too_late_message(self): 

237 return _( 

238 "Cancellation isn't possible anymore without having to pay " 

239 "the full costs of €" + str(self.fine) + ". Also note that " 

240 "you will be unable to re-register." 

241 ) 

242 

243 @property 

244 def after_cancel_deadline(self): 

245 return self.cancel_deadline and self.cancel_deadline <= timezone.now() 

246 

247 @property 

248 def registration_started(self): 

249 return self.registration_start <= timezone.now() 

250 

251 @property 

252 def registration_required(self): 

253 return bool(self.registration_start) or bool(self.registration_end) 

254 

255 @property 

256 def payment_required(self): 

257 return self.price != 0 

258 

259 @property 

260 def has_fields(self): 

261 return self.registrationinformationfield_set.count() > 0 

262 

263 participant_count = AggregateProperty( 

264 Count( 

265 "eventregistration", 

266 filter=Q(eventregistration__date_cancelled=None), 

267 ) 

268 ) 

269 

270 def reached_participants_limit(self): 

271 """Is this event up to capacity?.""" 

272 return ( 

273 self.max_participants is not None 

274 and self.max_participants <= self.participant_count 

275 ) 

276 

277 @property 

278 def registrations(self): 

279 """Queryset with all non-cancelled registrations.""" 

280 return self.eventregistration_set.filter(date_cancelled=None) 

281 

282 @property 

283 def participants(self): 

284 """Return the active participants.""" 

285 if self.max_participants is not None: 

286 return self.registrations.order_by("date")[: self.max_participants] 

287 return self.registrations.order_by("date") 

288 

289 @property 

290 def queue(self): 

291 """Return the waiting queue.""" 

292 if self.max_participants is not None: 

293 return self.registrations.order_by("date")[self.max_participants :] 

294 return [] 

295 

296 @property 

297 def cancellations(self): 

298 """Return a queryset with the cancelled events.""" 

299 return self.eventregistration_set.exclude(date_cancelled=None).order_by( 

300 "date_cancelled" 

301 ) 

302 

303 @property 

304 def registration_allowed(self): 

305 now = timezone.now() 

306 return ( 

307 bool(self.registration_start or self.registration_end) 

308 and self.registration_end > now >= self.registration_start 

309 ) 

310 

311 @property 

312 def cancellation_allowed(self): 

313 now = timezone.now() 

314 return ( 

315 bool(self.registration_start or self.registration_end) 

316 and self.registration_start <= now < self.start 

317 ) 

318 

319 @property 

320 def optional_registration_allowed(self): 

321 return ( 

322 self.optional_registrations 

323 and not self.registration_required 

324 and self.end >= timezone.now() 

325 ) 

326 

327 @property 

328 def has_food_event(self): 

329 try: 

330 self.food_event 

331 return True 

332 except ObjectDoesNotExist: 

333 return False 

334 

335 @property 

336 def location_link(self): 

337 """Return the link to the location on google maps.""" 

338 if self.show_map_location is False: 338 ↛ 339line 338 didn't jump to line 339 because the condition on line 338 was never true

339 return None 

340 return "https://www.google.com/maps/place/" + self.map_location.replace( 

341 " ", "+" 

342 ) 

343 

344 def clean_changes(self, changed_data): 

345 """Check if changes from `changed_data` are allowed. 

346 

347 This method should be run from a form clean() method, where changed_data 

348 can be retrieved from self.changed_data 

349 """ 

350 errors = {} 

351 if self.published or self.participant_count > 0: 

352 for field in ("price", "registration_start"): 

353 if ( 

354 field in changed_data 

355 and self.registration_start 

356 and self.registration_start <= timezone.now() 

357 ): 

358 errors.update( 

359 { 

360 field: _( 

361 "You cannot change this field after " 

362 "the registration has started." 

363 ) 

364 } 

365 ) 

366 

367 if errors: 

368 raise ValidationError(errors) 

369 

370 def clean(self): 

371 super().clean() 

372 errors = {} 

373 if self.start is None: 373 ↛ 374line 373 didn't jump to line 374 because the condition on line 373 was never true

374 errors.update({"start": _("Start cannot have an empty date or time field")}) 

375 if self.end is None: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true

376 errors.update({"end": _("End cannot have an empty date or time field")}) 

377 if self.start is not None and self.end is not None: 377 ↛ 491line 377 didn't jump to line 491 because the condition on line 377 was always true

378 if self.end < self.start: 

379 errors.update({"end": _("Can't have an event travel back in time")}) 

380 if self.registration_required: 

381 if self.optional_registrations: 381 ↛ 382line 381 didn't jump to line 382 because the condition on line 381 was never true

382 errors.update( 

383 { 

384 "optional_registrations": _( 

385 "This is not possible when actual registrations are required." 

386 ) 

387 } 

388 ) 

389 if self.fine < 5: 

390 errors.update( 

391 { 

392 "fine": _( 

393 "The fine for this event is too low " 

394 "(must be at least €5)." 

395 ) 

396 } 

397 ) 

398 if self.no_registration_message: 

399 errors.update( 

400 { 

401 "no_registration_message": _( 

402 "Doesn't make sense to have this " 

403 "if you require registrations." 

404 ) 

405 } 

406 ) 

407 if not self.registration_start: 

408 errors.update( 

409 { 

410 "registration_start": _( 

411 "If registration is required, you need a start of " 

412 "registration" 

413 ) 

414 } 

415 ) 

416 if not self.registration_end: 

417 errors.update( 

418 { 

419 "registration_end": _( 

420 "If registration is required, you need an end of " 

421 "registration" 

422 ) 

423 } 

424 ) 

425 if not self.cancel_deadline: 

426 errors.update( 

427 { 

428 "cancel_deadline": _( 

429 "If registration is required, " 

430 "you need a deadline for the cancellation" 

431 ) 

432 } 

433 ) 

434 elif self.cancel_deadline > self.start: 

435 errors.update( 

436 { 

437 "cancel_deadline": _( 

438 "The cancel deadline should be" 

439 " before the start of the event." 

440 ) 

441 } 

442 ) 

443 if self.update_deadline is not None: 443 ↛ 444line 443 didn't jump to line 444 because the condition on line 443 was never true

444 if self.update_deadline < self.registration_end: 

445 errors.update( 

446 { 

447 "update_deadline": _( 

448 "The update deadline should be " 

449 "after the registration deadline." 

450 ) 

451 } 

452 ) 

453 if self.update_deadline > self.start: 

454 errors.update( 

455 { 

456 "update_deadline": _( 

457 "The update deadline should be " 

458 "before the start of the event." 

459 ) 

460 } 

461 ) 

462 if ( 

463 self.registration_start 

464 and self.registration_end 

465 and (self.registration_start >= self.registration_end) 

466 ): 

467 message = _("Registration start should be before registration end") 

468 errors.update( 

469 {"registration_start": message, "registration_end": message} 

470 ) 

471 else: 

472 if self.cancel_deadline: 472 ↛ 473line 472 didn't jump to line 473 because the condition on line 472 was never true

473 errors.update( 

474 { 

475 "cancel_deadline": _( 

476 "There can be no cancellation deadline" 

477 " when no registration is required." 

478 ) 

479 } 

480 ) 

481 if self.update_deadline: 481 ↛ 482line 481 didn't jump to line 482 because the condition on line 481 was never true

482 errors.update( 

483 { 

484 "update_deadline": _( 

485 "There can be no update deadline " 

486 "when no registration is required." 

487 ) 

488 } 

489 ) 

490 

491 if errors: 

492 raise ValidationError(errors) 

493 

494 def get_absolute_url(self): 

495 if self.slug is None: 495 ↛ 497line 495 didn't jump to line 497 because the condition on line 495 was always true

496 return reverse("events:event", kwargs={"pk": self.pk}) 

497 return reverse("events:event", kwargs={"slug": self.slug}) 

498 

499 def delete(self, using=None, keep_parents=False): 

500 using = using or router.db_for_write(self.__class__, instance=self) 

501 collector = Collector(using=using) 

502 collector.collect([self], keep_parents=keep_parents) 

503 

504 if self.has_food_event: 

505 collector.add([self.food_event]) 

506 return collector.delete() 

507 

508 def __str__(self): 

509 return f"{self.title}: {timezone.localtime(self.start):%Y-%m-%d %H:%M}" 

510 

511 DEFAULT_STATUS_MESSAGE = { 

512 status.STATUS_WILL_OPEN: _("Registration will open {regstart}."), 

513 status.STATUS_EXPIRED: _("Registration is not possible anymore."), 

514 status.STATUS_OPEN: _("You can register now."), 

515 status.STATUS_FULL: _( 

516 "Registrations are full, but you can join the waiting list." 

517 ), 

518 status.STATUS_WAITINGLIST: _("You are in queue position {pos}."), 

519 status.STATUS_REGISTERED: _("You are registered for this event."), 

520 status.STATUS_CANCELLED: _( 

521 "Your registration for this event is cancelled. You may still re-register." 

522 ), 

523 status.STATUS_CANCELLED_FINAL: _( 

524 "Your registration for this event is cancelled. Note that you cannot re-register." 

525 ), 

526 status.STATUS_CANCELLED_LATE: _( 

527 "Your registration is cancelled after the deadline and you will pay a fine of €{fine}." 

528 ), 

529 status.STATUS_OPTIONAL: _("You can optionally register for this event."), 

530 status.STATUS_OPTIONAL_REGISTERED: _( 

531 "You are optionally registered for this event." 

532 ), 

533 status.STATUS_NONE: DEFAULT_NO_REGISTRATION_MESSAGE, 

534 status.STATUS_LOGIN: _( 

535 "You have to log in before you can register for this event." 

536 ), 

537 } 

538 

539 STATUS_MESSAGE_FIELDS = { 

540 status.STATUS_WILL_OPEN: "registration_msg_will_open", 

541 status.STATUS_EXPIRED: "registration_msg_expired", 

542 status.STATUS_OPEN: "registration_msg_open", 

543 status.STATUS_FULL: "registration_msg_full", 

544 status.STATUS_WAITINGLIST: "registration_msg_waitinglist", 

545 status.STATUS_REGISTERED: "registration_msg_registered", 

546 status.STATUS_CANCELLED_FINAL: "registration_msg_cancelled_final", 

547 status.STATUS_CANCELLED: "registration_msg_cancelled", 

548 status.STATUS_CANCELLED_LATE: "registration_msg_cancelled_late", 

549 status.STATUS_OPTIONAL: "registration_msg_optional", 

550 status.STATUS_OPTIONAL_REGISTERED: "registration_msg_optional_registered", 

551 status.STATUS_NONE: "no_registration_message", 

552 } 

553 

554 registration_msg_will_open = models.CharField( 

555 _( 

556 "message when registrations are still closed (and the user is not registered)" 

557 ), 

558 max_length=200, 

559 blank=True, 

560 null=True, 

561 help_text=format_lazy( 

562 "{} {}", 

563 _("Default:"), 

564 DEFAULT_STATUS_MESSAGE[status.STATUS_WILL_OPEN], 

565 ), 

566 ) 

567 registration_msg_expired = models.CharField( 

568 _( 

569 "message when the registration deadline expired and the user is not registered" 

570 ), 

571 max_length=200, 

572 blank=True, 

573 null=True, 

574 help_text=format_lazy( 

575 "{} {}", 

576 _("Default:"), 

577 DEFAULT_STATUS_MESSAGE[status.STATUS_EXPIRED], 

578 ), 

579 ) 

580 registration_msg_open = models.CharField( 

581 _("message when registrations are open and the user is not registered"), 

582 max_length=200, 

583 blank=True, 

584 null=True, 

585 help_text=format_lazy( 

586 "{} {}", 

587 _("Default:"), 

588 DEFAULT_STATUS_MESSAGE[status.STATUS_OPEN], 

589 ), 

590 ) 

591 registration_msg_full = models.CharField( 

592 _( 

593 "message when registrations are open, but full and the user is not registered" 

594 ), 

595 max_length=200, 

596 blank=True, 

597 null=True, 

598 help_text=format_lazy( 

599 "{} {}", 

600 _("Default:"), 

601 DEFAULT_STATUS_MESSAGE[status.STATUS_FULL], 

602 ), 

603 ) 

604 registration_msg_waitinglist = models.CharField( 

605 _("message when user is on the waiting list"), 

606 max_length=200, 

607 blank=True, 

608 null=True, 

609 help_text=format_lazy( 

610 "{} {}", 

611 _("Default:"), 

612 DEFAULT_STATUS_MESSAGE[status.STATUS_WAITINGLIST], 

613 ), 

614 ) 

615 registration_msg_registered = models.CharField( 

616 _("message when user is registered"), 

617 max_length=200, 

618 blank=True, 

619 null=True, 

620 help_text=format_lazy( 

621 "{} {}", 

622 _("Default:"), 

623 DEFAULT_STATUS_MESSAGE[status.STATUS_REGISTERED], 

624 ), 

625 ) 

626 registration_msg_cancelled = models.CharField( 

627 _("message when user cancelled their registration in time"), 

628 max_length=200, 

629 blank=True, 

630 null=True, 

631 help_text=format_lazy( 

632 "{} {}", 

633 _("Default:"), 

634 DEFAULT_STATUS_MESSAGE[status.STATUS_CANCELLED], 

635 ), 

636 ) 

637 registration_msg_cancelled_final = models.CharField( 

638 _( 

639 "message when user cancelled their registration in time and cannot re-register" 

640 ), 

641 max_length=200, 

642 blank=True, 

643 null=True, 

644 help_text=format_lazy( 

645 "{} {}", 

646 _("Default:"), 

647 DEFAULT_STATUS_MESSAGE[status.STATUS_CANCELLED_FINAL], 

648 ), 

649 ) 

650 registration_msg_cancelled_late = models.CharField( 

651 _("message when user cancelled their registration late and will pay a fine"), 

652 max_length=200, 

653 blank=True, 

654 null=True, 

655 help_text=format_lazy( 

656 "{} {}", 

657 _("Default:"), 

658 DEFAULT_STATUS_MESSAGE[status.STATUS_CANCELLED_LATE], 

659 ), 

660 ) 

661 registration_msg_optional = models.CharField( 

662 _("message when registrations are optional and the user is not registered"), 

663 max_length=200, 

664 blank=True, 

665 null=True, 

666 help_text=format_lazy( 

667 "{} {}", 

668 _("Default:"), 

669 DEFAULT_STATUS_MESSAGE[status.STATUS_OPTIONAL], 

670 ), 

671 ) 

672 registration_msg_optional_registered = models.CharField( 

673 _("message when registrations are optional and the user is registered"), 

674 max_length=200, 

675 blank=True, 

676 null=True, 

677 help_text=format_lazy( 

678 "{} {}", 

679 _("Default:"), 

680 DEFAULT_STATUS_MESSAGE[status.STATUS_OPTIONAL_REGISTERED], 

681 ), 

682 ) 

683 

684 class Meta: 

685 ordering = ("-start",) 

686 permissions = ( 

687 ("override_organiser", "Can access events as if organizing"), 

688 ("view_unpublished", "Can see any unpublished event"), 

689 )