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
« prev ^ index » next coverage.py v7.6.12, created at 2026-06-21 23:59 +0000
1import uuid
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 _
14from queryable_properties.managers import QueryablePropertiesManager
15from queryable_properties.properties import AggregateProperty
16from tinymce.models import HTMLField
18from events.models import status
19from events.models.categories import EVENT_CATEGORIES
20from payments.models import PaymentAmountField
23class Event(models.Model):
24 """Describes an event."""
26 objects = QueryablePropertiesManager()
28 DEFAULT_NO_REGISTRATION_MESSAGE = _("No registration required")
30 title = models.CharField(_("title"), max_length=100)
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 )
43 description = HTMLField(
44 _("description"),
45 )
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 )
57 start = models.DateTimeField(_("start time"))
59 end = models.DateTimeField(_("end time"))
61 organisers = models.ManyToManyField(
62 "activemembers.MemberGroup",
63 verbose_name=_("organisers"),
64 related_name=_("event_organiser"),
65 )
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 )
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 )
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 )
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 )
116 cancel_deadline = models.DateTimeField(_("cancel deadline"), null=True, blank=True)
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 )
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 )
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 )
149 location = models.CharField(
150 _("location"),
151 max_length=255,
152 )
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 )
165 show_map_location = models.BooleanField(
166 _("show url for location"),
167 default=True,
168 )
170 price = PaymentAmountField(
171 verbose_name=_("price"),
172 allow_zero=True,
173 default=0,
174 validators=[validators.MinValueValidator(0)],
175 )
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 )
187 max_participants = models.PositiveSmallIntegerField(
188 _("maximum number of participants"),
189 blank=True,
190 null=True,
191 )
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 <a href="https://example.com" target="_blank">https://example.com</a>'
205 ),
206 )
207 ),
208 )
210 published = models.BooleanField(_("published"), default=False)
212 documents = models.ManyToManyField(
213 "documents.Document",
214 verbose_name=_("documents"),
215 blank=True,
216 )
218 tpay_allowed = models.BooleanField(_("Allow Thalia Pay"), default=True)
220 mark_present_url_token = models.UUIDField(
221 unique=True, default=uuid.uuid4, editable=False
222 )
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 )
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 )
243 @property
244 def after_cancel_deadline(self):
245 return self.cancel_deadline and self.cancel_deadline <= timezone.now()
247 @property
248 def registration_started(self):
249 return self.registration_start <= timezone.now()
251 @property
252 def registration_required(self):
253 return bool(self.registration_start) or bool(self.registration_end)
255 @property
256 def payment_required(self):
257 return self.price != 0
259 @property
260 def has_fields(self):
261 return self.registrationinformationfield_set.count() > 0
263 participant_count = AggregateProperty(
264 Count(
265 "eventregistration",
266 filter=Q(eventregistration__date_cancelled=None),
267 )
268 )
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 )
277 @property
278 def registrations(self):
279 """Queryset with all non-cancelled registrations."""
280 return self.eventregistration_set.filter(date_cancelled=None)
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")
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 []
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 )
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 )
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 )
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 )
327 @property
328 def has_food_event(self):
329 try:
330 self.food_event
331 return True
332 except ObjectDoesNotExist:
333 return False
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 )
344 def clean_changes(self, changed_data):
345 """Check if changes from `changed_data` are allowed.
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 )
367 if errors:
368 raise ValidationError(errors)
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 )
491 if errors:
492 raise ValidationError(errors)
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})
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)
504 if self.has_food_event:
505 collector.add([self.food_event])
506 return collector.delete()
508 def __str__(self):
509 return f"{self.title}: {timezone.localtime(self.start):%Y-%m-%d %H:%M}"
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 }
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 }
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 )
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 )