Coverage for website/events/models/event.py: 80.58%
186 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
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 shift = models.OneToOneField("sales.Shift", models.SET_NULL, null=True, blank=True)
222 mark_present_url_token = models.UUIDField(
223 unique=True, default=uuid.uuid4, editable=False
224 )
226 @property
227 def mark_present_url(self):
228 """Return a url that a user can use to mark themselves present."""
229 return settings.BASE_URL + reverse(
230 "events:mark-present",
231 kwargs={
232 "pk": self.pk,
233 "token": self.mark_present_url_token,
234 },
235 )
237 @property
238 def cancel_too_late_message(self):
239 return _(
240 "Cancellation isn't possible anymore without having to pay "
241 "the full costs of €" + str(self.fine) + ". Also note that "
242 "you will be unable to re-register."
243 )
245 @property
246 def after_cancel_deadline(self):
247 return self.cancel_deadline and self.cancel_deadline <= timezone.now()
249 @property
250 def registration_started(self):
251 return self.registration_start <= timezone.now()
253 @property
254 def registration_required(self):
255 return bool(self.registration_start) or bool(self.registration_end)
257 @property
258 def payment_required(self):
259 return self.price != 0
261 @property
262 def has_fields(self):
263 return self.registrationinformationfield_set.count() > 0
265 participant_count = AggregateProperty(
266 Count(
267 "eventregistration",
268 filter=Q(eventregistration__date_cancelled=None),
269 )
270 )
272 def reached_participants_limit(self):
273 """Is this event up to capacity?."""
274 return (
275 self.max_participants is not None
276 and self.max_participants <= self.participant_count
277 )
279 @property
280 def registrations(self):
281 """Queryset with all non-cancelled registrations."""
282 return self.eventregistration_set.filter(date_cancelled=None)
284 @property
285 def participants(self):
286 """Return the active participants."""
287 if self.max_participants is not None:
288 return self.registrations.order_by("date")[: self.max_participants]
289 return self.registrations.order_by("date")
291 @property
292 def queue(self):
293 """Return the waiting queue."""
294 if self.max_participants is not None:
295 return self.registrations.order_by("date")[self.max_participants :]
296 return []
298 @property
299 def cancellations(self):
300 """Return a queryset with the cancelled events."""
301 return self.eventregistration_set.exclude(date_cancelled=None).order_by(
302 "date_cancelled"
303 )
305 @property
306 def registration_allowed(self):
307 now = timezone.now()
308 return (
309 bool(self.registration_start or self.registration_end)
310 and self.registration_end > now >= self.registration_start
311 )
313 @property
314 def cancellation_allowed(self):
315 now = timezone.now()
316 return (
317 bool(self.registration_start or self.registration_end)
318 and self.registration_start <= now < self.start
319 )
321 @property
322 def optional_registration_allowed(self):
323 return (
324 self.optional_registrations
325 and not self.registration_required
326 and self.end >= timezone.now()
327 )
329 @property
330 def has_food_event(self):
331 try:
332 self.food_event
333 return True
334 except ObjectDoesNotExist:
335 return False
337 @property
338 def location_link(self):
339 """Return the link to the location on google maps."""
340 if self.show_map_location is False: 340 ↛ 341line 340 didn't jump to line 341 because the condition on line 340 was never true
341 return None
342 return "https://www.google.com/maps/place/" + self.map_location.replace(
343 " ", "+"
344 )
346 def clean_changes(self, changed_data):
347 """Check if changes from `changed_data` are allowed.
349 This method should be run from a form clean() method, where changed_data
350 can be retrieved from self.changed_data
351 """
352 errors = {}
353 if self.published or self.participant_count > 0:
354 for field in ("price", "registration_start"):
355 if (
356 field in changed_data
357 and self.registration_start
358 and self.registration_start <= timezone.now()
359 ):
360 errors.update(
361 {
362 field: _(
363 "You cannot change this field after "
364 "the registration has started."
365 )
366 }
367 )
369 if errors:
370 raise ValidationError(errors)
372 def clean(self):
373 super().clean()
374 errors = {}
375 if self.start is None: 375 ↛ 376line 375 didn't jump to line 376 because the condition on line 375 was never true
376 errors.update({"start": _("Start cannot have an empty date or time field")})
377 if self.end is None: 377 ↛ 378line 377 didn't jump to line 378 because the condition on line 377 was never true
378 errors.update({"end": _("End cannot have an empty date or time field")})
379 if self.start is not None and self.end is not None: 379 ↛ 493line 379 didn't jump to line 493 because the condition on line 379 was always true
380 if self.end < self.start:
381 errors.update({"end": _("Can't have an event travel back in time")})
382 if self.registration_required:
383 if self.optional_registrations: 383 ↛ 384line 383 didn't jump to line 384 because the condition on line 383 was never true
384 errors.update(
385 {
386 "optional_registrations": _(
387 "This is not possible when actual registrations are required."
388 )
389 }
390 )
391 if self.fine < 5:
392 errors.update(
393 {
394 "fine": _(
395 "The fine for this event is too low "
396 "(must be at least €5)."
397 )
398 }
399 )
400 if self.no_registration_message:
401 errors.update(
402 {
403 "no_registration_message": _(
404 "Doesn't make sense to have this "
405 "if you require registrations."
406 )
407 }
408 )
409 if not self.registration_start:
410 errors.update(
411 {
412 "registration_start": _(
413 "If registration is required, you need a start of "
414 "registration"
415 )
416 }
417 )
418 if not self.registration_end:
419 errors.update(
420 {
421 "registration_end": _(
422 "If registration is required, you need an end of "
423 "registration"
424 )
425 }
426 )
427 if not self.cancel_deadline:
428 errors.update(
429 {
430 "cancel_deadline": _(
431 "If registration is required, "
432 "you need a deadline for the cancellation"
433 )
434 }
435 )
436 elif self.cancel_deadline > self.start:
437 errors.update(
438 {
439 "cancel_deadline": _(
440 "The cancel deadline should be"
441 " before the start of the event."
442 )
443 }
444 )
445 if self.update_deadline is not None: 445 ↛ 446line 445 didn't jump to line 446 because the condition on line 445 was never true
446 if self.update_deadline < self.registration_end:
447 errors.update(
448 {
449 "update_deadline": _(
450 "The update deadline should be "
451 "after the registration deadline."
452 )
453 }
454 )
455 if self.update_deadline > self.start:
456 errors.update(
457 {
458 "update_deadline": _(
459 "The update deadline should be "
460 "before the start of the event."
461 )
462 }
463 )
464 if (
465 self.registration_start
466 and self.registration_end
467 and (self.registration_start >= self.registration_end)
468 ):
469 message = _("Registration start should be before registration end")
470 errors.update(
471 {"registration_start": message, "registration_end": message}
472 )
473 else:
474 if self.cancel_deadline: 474 ↛ 475line 474 didn't jump to line 475 because the condition on line 474 was never true
475 errors.update(
476 {
477 "cancel_deadline": _(
478 "There can be no cancellation deadline"
479 " when no registration is required."
480 )
481 }
482 )
483 if self.update_deadline: 483 ↛ 484line 483 didn't jump to line 484 because the condition on line 483 was never true
484 errors.update(
485 {
486 "update_deadline": _(
487 "There can be no update deadline "
488 "when no registration is required."
489 )
490 }
491 )
493 if errors:
494 raise ValidationError(errors)
496 def get_absolute_url(self):
497 if self.slug is None: 497 ↛ 499line 497 didn't jump to line 499 because the condition on line 497 was always true
498 return reverse("events:event", kwargs={"pk": self.pk})
499 return reverse("events:event", kwargs={"slug": self.slug})
501 def delete(self, using=None, keep_parents=False):
502 using = using or router.db_for_write(self.__class__, instance=self)
503 collector = Collector(using=using)
504 collector.collect([self], keep_parents=keep_parents)
506 if self.has_food_event:
507 collector.add([self.food_event])
508 return collector.delete()
510 def __str__(self):
511 return f"{self.title}: {timezone.localtime(self.start):%Y-%m-%d %H:%M}"
513 DEFAULT_STATUS_MESSAGE = {
514 status.STATUS_WILL_OPEN: _("Registration will open {regstart}."),
515 status.STATUS_EXPIRED: _("Registration is not possible anymore."),
516 status.STATUS_OPEN: _("You can register now."),
517 status.STATUS_FULL: _(
518 "Registrations are full, but you can join the waiting list."
519 ),
520 status.STATUS_WAITINGLIST: _("You are in queue position {pos}."),
521 status.STATUS_REGISTERED: _("You are registered for this event."),
522 status.STATUS_CANCELLED: _(
523 "Your registration for this event is cancelled. You may still re-register."
524 ),
525 status.STATUS_CANCELLED_FINAL: _(
526 "Your registration for this event is cancelled. Note that you cannot re-register."
527 ),
528 status.STATUS_CANCELLED_LATE: _(
529 "Your registration is cancelled after the deadline and you will pay a fine of €{fine}."
530 ),
531 status.STATUS_OPTIONAL: _("You can optionally register for this event."),
532 status.STATUS_OPTIONAL_REGISTERED: _(
533 "You are optionally registered for this event."
534 ),
535 status.STATUS_NONE: DEFAULT_NO_REGISTRATION_MESSAGE,
536 status.STATUS_LOGIN: _(
537 "You have to log in before you can register for this event."
538 ),
539 }
541 STATUS_MESSAGE_FIELDS = {
542 status.STATUS_WILL_OPEN: "registration_msg_will_open",
543 status.STATUS_EXPIRED: "registration_msg_expired",
544 status.STATUS_OPEN: "registration_msg_open",
545 status.STATUS_FULL: "registration_msg_full",
546 status.STATUS_WAITINGLIST: "registration_msg_waitinglist",
547 status.STATUS_REGISTERED: "registration_msg_registered",
548 status.STATUS_CANCELLED_FINAL: "registration_msg_cancelled_final",
549 status.STATUS_CANCELLED: "registration_msg_cancelled",
550 status.STATUS_CANCELLED_LATE: "registration_msg_cancelled_late",
551 status.STATUS_OPTIONAL: "registration_msg_optional",
552 status.STATUS_OPTIONAL_REGISTERED: "registration_msg_optional_registered",
553 status.STATUS_NONE: "no_registration_message",
554 }
556 registration_msg_will_open = models.CharField(
557 _(
558 "message when registrations are still closed (and the user is not registered)"
559 ),
560 max_length=200,
561 blank=True,
562 null=True,
563 help_text=format_lazy(
564 "{} {}",
565 _("Default:"),
566 DEFAULT_STATUS_MESSAGE[status.STATUS_WILL_OPEN],
567 ),
568 )
569 registration_msg_expired = models.CharField(
570 _(
571 "message when the registration deadline expired and the user is not registered"
572 ),
573 max_length=200,
574 blank=True,
575 null=True,
576 help_text=format_lazy(
577 "{} {}",
578 _("Default:"),
579 DEFAULT_STATUS_MESSAGE[status.STATUS_EXPIRED],
580 ),
581 )
582 registration_msg_open = models.CharField(
583 _("message when registrations are open and the user is not registered"),
584 max_length=200,
585 blank=True,
586 null=True,
587 help_text=format_lazy(
588 "{} {}",
589 _("Default:"),
590 DEFAULT_STATUS_MESSAGE[status.STATUS_OPEN],
591 ),
592 )
593 registration_msg_full = models.CharField(
594 _(
595 "message when registrations are open, but full and the user is not registered"
596 ),
597 max_length=200,
598 blank=True,
599 null=True,
600 help_text=format_lazy(
601 "{} {}",
602 _("Default:"),
603 DEFAULT_STATUS_MESSAGE[status.STATUS_FULL],
604 ),
605 )
606 registration_msg_waitinglist = models.CharField(
607 _("message when user is on the waiting list"),
608 max_length=200,
609 blank=True,
610 null=True,
611 help_text=format_lazy(
612 "{} {}",
613 _("Default:"),
614 DEFAULT_STATUS_MESSAGE[status.STATUS_WAITINGLIST],
615 ),
616 )
617 registration_msg_registered = models.CharField(
618 _("message when user is registered"),
619 max_length=200,
620 blank=True,
621 null=True,
622 help_text=format_lazy(
623 "{} {}",
624 _("Default:"),
625 DEFAULT_STATUS_MESSAGE[status.STATUS_REGISTERED],
626 ),
627 )
628 registration_msg_cancelled = models.CharField(
629 _("message when user cancelled their registration in time"),
630 max_length=200,
631 blank=True,
632 null=True,
633 help_text=format_lazy(
634 "{} {}",
635 _("Default:"),
636 DEFAULT_STATUS_MESSAGE[status.STATUS_CANCELLED],
637 ),
638 )
639 registration_msg_cancelled_final = models.CharField(
640 _(
641 "message when user cancelled their registration in time and cannot re-register"
642 ),
643 max_length=200,
644 blank=True,
645 null=True,
646 help_text=format_lazy(
647 "{} {}",
648 _("Default:"),
649 DEFAULT_STATUS_MESSAGE[status.STATUS_CANCELLED_FINAL],
650 ),
651 )
652 registration_msg_cancelled_late = models.CharField(
653 _("message when user cancelled their registration late and will pay a fine"),
654 max_length=200,
655 blank=True,
656 null=True,
657 help_text=format_lazy(
658 "{} {}",
659 _("Default:"),
660 DEFAULT_STATUS_MESSAGE[status.STATUS_CANCELLED_LATE],
661 ),
662 )
663 registration_msg_optional = models.CharField(
664 _("message when registrations are optional and the user is not registered"),
665 max_length=200,
666 blank=True,
667 null=True,
668 help_text=format_lazy(
669 "{} {}",
670 _("Default:"),
671 DEFAULT_STATUS_MESSAGE[status.STATUS_OPTIONAL],
672 ),
673 )
674 registration_msg_optional_registered = models.CharField(
675 _("message when registrations are optional and the user is registered"),
676 max_length=200,
677 blank=True,
678 null=True,
679 help_text=format_lazy(
680 "{} {}",
681 _("Default:"),
682 DEFAULT_STATUS_MESSAGE[status.STATUS_OPTIONAL_REGISTERED],
683 ),
684 )
686 class Meta:
687 ordering = ("-start",)
688 permissions = (
689 ("override_organiser", "Can access events as if organizing"),
690 ("view_unpublished", "Can see any unpublished event"),
691 )