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

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 shift = models.OneToOneField("sales.Shift", models.SET_NULL, null=True, blank=True) 

221 

222 mark_present_url_token = models.UUIDField( 

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

224 ) 

225 

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 ) 

236 

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 ) 

244 

245 @property 

246 def after_cancel_deadline(self): 

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

248 

249 @property 

250 def registration_started(self): 

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

252 

253 @property 

254 def registration_required(self): 

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

256 

257 @property 

258 def payment_required(self): 

259 return self.price != 0 

260 

261 @property 

262 def has_fields(self): 

263 return self.registrationinformationfield_set.count() > 0 

264 

265 participant_count = AggregateProperty( 

266 Count( 

267 "eventregistration", 

268 filter=Q(eventregistration__date_cancelled=None), 

269 ) 

270 ) 

271 

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 ) 

278 

279 @property 

280 def registrations(self): 

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

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

283 

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

290 

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 [] 

297 

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 ) 

304 

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 ) 

312 

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 ) 

320 

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 ) 

328 

329 @property 

330 def has_food_event(self): 

331 try: 

332 self.food_event 

333 return True 

334 except ObjectDoesNotExist: 

335 return False 

336 

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 ) 

345 

346 def clean_changes(self, changed_data): 

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

348 

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 ) 

368 

369 if errors: 

370 raise ValidationError(errors) 

371 

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 ) 

492 

493 if errors: 

494 raise ValidationError(errors) 

495 

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

500 

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) 

505 

506 if self.has_food_event: 

507 collector.add([self.food_event]) 

508 return collector.delete() 

509 

510 def __str__(self): 

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

512 

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 } 

540 

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 } 

555 

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 ) 

685 

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 )