models.py 21.9 KB
Newer Older
1
"""The models defined by the events package"""
2
from django.conf import settings
Thom Wiggers's avatar
Thom Wiggers committed
3
from django.core import validators
4
from django.core.exceptions import ValidationError, ObjectDoesNotExist
Thom Wiggers's avatar
Thom Wiggers committed
5
6
from django.db import models
from django.db.models import Q
7
from django.urls import reverse
Thom Wiggers's avatar
Thom Wiggers committed
8
from django.utils import timezone
9
from django.utils.crypto import get_random_string
10
from django.utils.translation import ugettext_lazy as _
11
from django.utils.text import format_lazy
12
from tinymce.models import HTMLField
13

14
15
from members.models import Member
from pushnotifications.models import ScheduledMessage, Category
16
from utils.translation import ModelTranslateMeta, MultilingualField
Thom Wiggers's avatar
Thom Wiggers committed
17
18


19
class Event(models.Model, metaclass=ModelTranslateMeta):
20
    """Describes an event"""
Thom Wiggers's avatar
Thom Wiggers committed
21

22
23
24
25
26
27
28
29
    EVENT_CATEGORIES = (
        ('drinks', _('Drinks')),
        ('activity', _('Activity')),
        ('lunchlecture', _('Lunch Lecture')),
        ('generalmeeting', _('General Meeting')),
        ('workshop', _('Workshop')),
        ('other', _('Other')))

Thom Wiggers's avatar
Thom Wiggers committed
30
31
    DEFAULT_NO_REGISTRATION_MESSAGE = _('No registration required')

32
33
34
35
36
    title = MultilingualField(
        models.CharField,
        _("title"),
        max_length=100
    )
Thom Wiggers's avatar
Thom Wiggers committed
37

38
    description = MultilingualField(
39
        HTMLField,
40
41
        _("description")
    )
Thom Wiggers's avatar
Thom Wiggers committed
42
43
44
45
46
47

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

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

    organiser = models.ForeignKey(
48
        'activemembers.MemberGroup',
Thom Wiggers's avatar
Thom Wiggers committed
49
        models.PROTECT,
50
        verbose_name=_("organiser")
Thom Wiggers's avatar
Thom Wiggers committed
51
52
    )

53
54
55
56
57
58
59
    category = models.CharField(
        max_length=40,
        choices=EVENT_CATEGORIES,
        verbose_name=_('category'),
        default='other'
    )

Thom Wiggers's avatar
Thom Wiggers committed
60
61
62
63
    registration_start = models.DateTimeField(
        _("registration start"),
        null=True,
        blank=True,
64
65
66
        help_text=_("If you set a registration period registration will be "
                    "required. If you don't set one, registration won't be "
                    "required.")
Thom Wiggers's avatar
Thom Wiggers committed
67
68
69
70
71
    )

    registration_end = models.DateTimeField(
        _("registration end"),
        null=True,
72
73
74
75
        blank=True,
        help_text=_("If you set a registration period registration will be "
                    "required. If you don't set one, registration won't be "
                    "required.")
76
77
78
79
80
    )

    cancel_deadline = models.DateTimeField(
        _("cancel deadline"),
        null=True,
Thom Wiggers's avatar
Thom Wiggers committed
81
82
83
        blank=True
    )

84
85
86
87
88
89
90
    send_cancel_email = models.BooleanField(
        _('send cancellation notifications'),
        default=True,
        help_text=_("Send an email to the organising party when a member "
                    "cancels their registration after the deadline."),
    )

91
92
93
94
95
    location = MultilingualField(
        models.CharField,
        _("location"),
        max_length=255,
    )
Thom Wiggers's avatar
Thom Wiggers committed
96
97
98
99
100

    map_location = models.CharField(
        _("location for minimap"),
        max_length=255,
        help_text=_('Location of Huygens: Heyendaalseweg 135, Nijmegen. '
101
                    'Location of Mercator 1: Toernooiveld 212, Nijmegen. '
Thom Wiggers's avatar
Thom Wiggers committed
102
103
104
105
106
107
108
109
110
111
112
                    'Not shown as text!!'),
    )

    price = models.DecimalField(
        _("price"),
        max_digits=5,
        decimal_places=2,
        default=0,
        validators=[validators.MinValueValidator(0)],
    )

113
114
    fine = models.DecimalField(
        _("fine"),
Thom Wiggers's avatar
Thom Wiggers committed
115
116
117
        max_digits=5,
        decimal_places=2,
        default=0,
118
119
        # Minimum fine is checked in this model's clean(), as it is only for
        # events that require registration.
120
        help_text=_("Fine if participant does not show up (at least €5)."),
121
        validators=[validators.MinValueValidator(0)],
Thom Wiggers's avatar
Thom Wiggers committed
122
123
124
125
126
127
128
129
    )

    max_participants = models.PositiveSmallIntegerField(
        _('maximum number of participants'),
        blank=True,
        null=True,
    )

130
131
    no_registration_message = MultilingualField(
        models.CharField,
Thom Wiggers's avatar
Thom Wiggers committed
132
133
134
135
        _('message when there is no registration'),
        max_length=200,
        blank=True,
        null=True,
136
137
        help_text=(format_lazy("{} {}", _("Default:"),
                               DEFAULT_NO_REGISTRATION_MESSAGE)),
Thom Wiggers's avatar
Thom Wiggers committed
138
139
140
141
    )

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

142
143
144
145
146
147
148
    registration_reminder = models.ForeignKey(
        ScheduledMessage, on_delete=models.deletion.SET_NULL,
        related_name='registration_event', blank=True, null=True)
    start_reminder = models.ForeignKey(
        ScheduledMessage, on_delete=models.deletion.SET_NULL,
        related_name='start_event', blank=True, null=True)

149
    @property
150
    def after_cancel_deadline(self):
151
152
153
154
155
        return self.cancel_deadline and self.cancel_deadline <= timezone.now()

    @property
    def registration_started(self):
        return self.registration_start <= timezone.now()
156

157
    @property
158
159
160
    def registration_required(self):
        return bool(self.registration_start) or bool(self.registration_end)

161
162
163
164
    def has_fields(self):
        return self.registrationinformationfield_set.count() > 0

    def reached_participants_limit(self):
165
        """Is this event up to capacity?"""
166
        return (self.max_participants is not None and
167
168
                self.max_participants <= self.registration_set.filter(
                    date_cancelled=None).count())
169

170
171
    @property
    def registrations(self):
172
        """Queryset with all non-cancelled registrations"""
173
174
175
176
        return self.registration_set.filter(date_cancelled=None)

    @property
    def participants(self):
177
        """Return the active participants"""
178
179
180
        if self.max_participants is not None:
            return self.registrations.order_by('date')[:self.max_participants]
        return self.registrations.order_by('date')
181
182
183

    @property
    def queue(self):
184
        """Return the waiting queue"""
185
186
187
        if self.max_participants is not None:
            return self.registrations.order_by('date')[self.max_participants:]
        return []
188

189
190
    @property
    def cancellations(self):
191
192
193
194
        """Return a queryset with the cancelled events"""
        return (self.registration_set
                .exclude(date_cancelled=None)
                .order_by('date_cancelled'))
195

196
    @property
197
    def registration_allowed(self):
198
        now = timezone.now()
199
200
201
202
203
204
205
        return ((self.registration_start or self.registration_end) and
                self.registration_end > now >= self.registration_start)

    @property
    def cancellation_allowed(self):
        now = timezone.now()
        return ((self.registration_start or self.registration_end)
206
                and self.registration_start <= now < self.start)
207

208
209
210
211
212
213
214
    def is_pizza_event(self):
        try:
            self.pizzaevent
            return True
        except ObjectDoesNotExist:
            return False

Thom Wiggers's avatar
Thom Wiggers committed
215
216
    def clean(self):
        super().clean()
Thom Wiggers's avatar
Thom Wiggers committed
217
        errors = {}
218
        if self.start is None:
Thom Wiggers's avatar
Thom Wiggers committed
219
            errors.update({
220
221
222
223
224
225
226
227
                'start': _("Start cannot have an empty date or time field")
            })
        if self.end is None:
            errors.update({
                'end': _("End cannot have an empty date or time field")
            })
        if self.start is not None and self.end is not None:
            if self.end < self.start:
228
                errors.update({
229
230
231
232
233
234
235
236
237
238
239
240
241
242
                    'end': _("Can't have an event travel back in time")})
            if self.registration_required:
                if self.fine < 5:
                    errors.update({
                        'fine': _("The fine for this event is too low "
                                  "(must be at least €5).")
                    })
                for lang in settings.LANGUAGES:
                    field = 'no_registration_message_' + lang[0]
                    if getattr(self, field):
                        errors.update(
                            {field: _("Doesn't make sense to have this "
                                      "if you require registrations.")})
                if not self.registration_start:
243
                    errors.update(
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
                        {'registration_start': _(
                            "If registration is required, you need a start of "
                            "registration")})
                if not self.registration_end:
                    errors.update(
                        {'registration_end': _(
                            "If registration is required, you need an end of "
                            "registration")})
                if not self.cancel_deadline:
                    errors.update(
                        {'cancel_deadline': _(
                            "If registration is required, "
                            "you need a deadline for the cancellation")})
                elif self.cancel_deadline > self.start:
                    errors.update(
                        {'cancel_deadline': _(
                            "The cancel deadline should be"
                            " before the start of the event.")})
                if self.registration_start and self.registration_end and (
                        self.registration_start >= self.registration_end):
                    message = _('Registration start should be before '
                                'registration end')
                    errors.update({
                        'registration_start': message,
                        'registration_end': message})
269
270
271
272
273
274
275
        if (self.organiser is not None and
                self.send_cancel_email and
                self.organiser.contact_mailinglist is None):
            errors.update(
                {'send_cancel_email': _("This organiser does not "
                                        "have a contact mailinglist.")})

Thom Wiggers's avatar
Thom Wiggers committed
276
277
        if errors:
            raise ValidationError(errors)
Thom Wiggers's avatar
Thom Wiggers committed
278
279

    def get_absolute_url(self):
280
        return reverse('events:event', args=[str(self.pk)])
Thom Wiggers's avatar
Thom Wiggers committed
281

282
    def save(self, *args, **kwargs):
283
284
        if self.published:
            if self.registration_required:
285
                registration_reminder_time = (self.registration_start -
286
                                              timezone.timedelta(hours=1))
287
288
                registration_reminder = ScheduledMessage()
                if (self.registration_reminder is not None
289
                        and not self.registration_reminder.sent):
290
                    registration_reminder = self.registration_reminder
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306

                if registration_reminder_time > timezone.now():
                    registration_reminder.title_en = 'Event registration'
                    registration_reminder.title_nl = 'Evenement registratie'
                    registration_reminder.body_en = ('Registration for \'{}\' '
                                                     'starts in 1 hour'
                                                     .format(self.title_en))
                    registration_reminder.body_nl = ('Registratie voor \'{}\' '
                                                     'start in 1 uur'
                                                     .format(self.title_nl))
                    registration_reminder.category = Category.objects.get(
                        key='event')
                    registration_reminder.time = registration_reminder_time
                    registration_reminder.save()
                    self.registration_reminder = registration_reminder
                    self.registration_reminder.users.set(
307
                        Member.current_members.all())
308
309
310
                elif registration_reminder.pk is not None:
                    self.registration_reminder = None
                    registration_reminder.delete()
311

312
            start_reminder_time = (self.start - timezone.timedelta(hours=1))
313
314
            start_reminder = ScheduledMessage()
            if (self.start_reminder is not None
315
                    and not self.start_reminder.sent):
316
                start_reminder = self.start_reminder
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332

            if start_reminder_time > timezone.now():
                start_reminder.title_en = 'Event'
                start_reminder.title_nl = 'Evenement'
                start_reminder.body_en = ('\'{}\' starts in '
                                          '1 hour'.format(self.title_en))
                start_reminder.body_nl = ('\'{}\' begint over '
                                          '1 uur'.format(self.title_nl))
                start_reminder.category = Category.objects.get(key='event')
                start_reminder.time = start_reminder_time
                start_reminder.save()
                self.start_reminder = start_reminder
                if self.registration_required:
                    self.start_reminder.users.set(self.participants.values_list(
                        'member', flat=True))
                else:
333
                    self.start_reminder.users.set(Member.current_members.all())
334
335
336
            elif start_reminder.pk is not None:
                self.start_reminder = None
                start_reminder.delete()
337
        else:
338
339
340
341
342
343
            if (self.registration_reminder is not None
                    and not self.registration_reminder.sent):
                self.registration_reminder.delete()
            if (self.start_reminder is not None
                    and not self.start_reminder.sent):
                self.start_reminder.delete()
344
345
346

        super().save(*args, **kwargs)

Thom Wiggers's avatar
Thom Wiggers committed
347
348
349
350
351
352
353
    def __str__(self):
        return '{}: {}'.format(
            self.title,
            timezone.localtime(self.start).strftime('%Y-%m-%d %H:%M'))

    class Meta:
        ordering = ('-start',)
354
355
356
        permissions = (
            ("override_organiser", "Can access events as if organizing"),
        )
Thom Wiggers's avatar
Thom Wiggers committed
357
358


359
def registration_member_choices_limit():
360
    """Defines queryset filters to only include current members"""
361
362
363
364
    return (Q(membership__until__isnull=True) |
            Q(membership__until__gt=timezone.now().date()))


Thom Wiggers's avatar
Thom Wiggers committed
365
class Registration(models.Model):
366
    """Describes a registration for an Event"""
Thom Wiggers's avatar
Thom Wiggers committed
367

368
369
370
371
    PAYMENT_CARD = 'card_payment'
    PAYMENT_CASH = 'cash_payment'
    PAYMENT_NONE = 'no_payment'

372
    PAYMENT_TYPES = (
373
374
375
        (PAYMENT_NONE, _('No payment')),
        (PAYMENT_CASH, _('Paid with cash')),
        (PAYMENT_CARD, _('Paid with card')))
376

Thom Wiggers's avatar
Thom Wiggers committed
377
378
379
    event = models.ForeignKey(Event, models.CASCADE)

    member = models.ForeignKey(
380
        'members.Member', models.CASCADE,
Thom Wiggers's avatar
Thom Wiggers committed
381
382
        blank=True,
        null=True,
383
        limit_choices_to=registration_member_choices_limit
Thom Wiggers's avatar
Thom Wiggers committed
384
385
386
387
388
389
390
391
392
393
    )

    name = models.CharField(
        _('name'),
        max_length=50,
        help_text=_('Use this for non-members'),
        null=True,
        blank=True
    )

394
395
    date = models.DateTimeField(_('registration date'),
                                default=timezone.now)
Thom Wiggers's avatar
Thom Wiggers committed
396
397
398
399
400
    date_cancelled = models.DateTimeField(_('cancellation date'),
                                          null=True,
                                          blank=True)

    present = models.BooleanField(
401
        _('present'),
Thom Wiggers's avatar
Thom Wiggers committed
402
403
        default=False,
    )
404
405
406
407
408
409

    payment = models.CharField(
        choices=PAYMENT_TYPES,
        default='no_payment',
        verbose_name=_('payment'),
        max_length=20,
Thom Wiggers's avatar
Thom Wiggers committed
410
411
    )

412
413
    @property
    def information_fields(self):
Thom Wiggers's avatar
Thom Wiggers committed
414
415
416
417
        fields = self.event.registrationinformationfield_set.all()
        return [{'field': field, 'value': field.get_value_for(self)}
                for field in fields]

418
419
420
421
422
423
424
425
426
427
428
429
430
    @property
    def is_registered(self):
        return self.date_cancelled is None

    @property
    def queue_position(self):
        if self.event.max_participants is not None:
            try:
                return list(self.event.queue).index(self) + 1
            except ValueError:
                pass
        return 0

431
432
433
434
435
    @property
    def is_invited(self):
        return (self.is_registered and
                self.queue_position == 0)

436
437
438
    def is_external(self):
        return bool(self.name)

Luuk Scholten's avatar
Luuk Scholten committed
439
    def is_late_cancellation(self):
440
441
        # First check whether or not the user cancelled
        # If the user cancelled then check if this was after the deadline
442
443
        # And if there is a max participants number:
        # do a complex check to calculate if this user was on
444
445
446
        # the waiting list at the time of cancellation, since
        # you shouldn't need to pay the costs of something
        # you weren't even able to go to.
Luuk Scholten's avatar
Luuk Scholten committed
447
        return (self.date_cancelled and
448
                self.event.cancel_deadline and
449
                self.date_cancelled > self.event.cancel_deadline and
450
451
                (self.event.max_participants is None or
                 self.event.registration_set.filter(
452
453
454
                     (Q(date_cancelled__gte=self.date_cancelled) |
                      Q(date_cancelled=None)) &
                     Q(date__lte=self.date)
455
                 ).count() < self.event.max_participants))
Luuk Scholten's avatar
Luuk Scholten committed
456

457
458
459
460
    def is_paid(self):
        return self.payment in [Registration.PAYMENT_CARD,
                                Registration.PAYMENT_CASH]

461
    def would_cancel_after_deadline(self):
462
463
464
        now = timezone.now()
        return (self.queue_position == 0 and
                now >= self.event.cancel_deadline)
465

Thom Wiggers's avatar
Thom Wiggers committed
466
467
468
469
470
471
472
473
474
475
476
    def clean(self):
        if ((self.member is None and not self.name) or
                (self.member and self.name)):
            raise ValidationError({
                'member': _('Either specify a member or a name'),
                'name': _('Either specify a member or a name'),
            })

    def validate_unique(self, exclude=None):
        super().validate_unique(exclude)

477
478
479
480
481
482
483
484
485
486
    def save(self, *args, **kwargs):
        super().save(*args, **kwargs)

        if self.event.start_reminder and self.date_cancelled:
            self.event.start_reminder.users.remove(self.member)
        elif (self.event.start_reminder and self.member is not None and
              not self.event.start_reminder.users
                  .filter(pk=self.member.pk).exists()):
            self.event.start_reminder.users.add(self.member)

Thom Wiggers's avatar
Thom Wiggers committed
487
488
489
490
491
492
493
494
495
496
497
    def __str__(self):
        if self.member:
            return '{}: {}'.format(self.member.get_full_name(), self.event)
        else:
            return '{}: {}'.format(self.name, self.event)

    class Meta:
        ordering = ('date',)
        unique_together = (('member', 'event', 'name', 'date_cancelled'),)


498
class RegistrationInformationField(models.Model, metaclass=ModelTranslateMeta):
499
    """Describes a field description to ask for when registering"""
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
    BOOLEAN_FIELD = 'boolean'
    INTEGER_FIELD = 'integer'
    TEXT_FIELD = 'text'

    FIELD_TYPES = ((BOOLEAN_FIELD, _('Checkbox')),
                   (TEXT_FIELD, _('Text')),
                   (INTEGER_FIELD, _('Integer')),)

    event = models.ForeignKey(Event, models.CASCADE)

    type = models.CharField(
        _('field type'),
        choices=FIELD_TYPES,
        max_length=10,
    )

    name = MultilingualField(
        models.CharField,
        _('field name'),
        max_length=100,
    )

    description = MultilingualField(
        models.TextField,
        _('description'),
        null=True,
        blank=True,
    )

    required = models.BooleanField(
        _('required'),
    )

    def get_value_for(self, registration):
        if self.type == self.TEXT_FIELD:
            value_set = self.textregistrationinformation_set
        elif self.type == self.BOOLEAN_FIELD:
            value_set = self.booleanregistrationinformation_set
        elif self.type == self.INTEGER_FIELD:
            value_set = self.integerregistrationinformation_set

        try:
            return value_set.get(registration=registration).value
        except (TextRegistrationInformation.DoesNotExist,
                BooleanRegistrationInformation.DoesNotExist,
                IntegerRegistrationInformation.DoesNotExist):
            return None

    def set_value_for(self, registration, value):
        if self.type == self.TEXT_FIELD:
            value_set = self.textregistrationinformation_set
        elif self.type == self.BOOLEAN_FIELD:
            value_set = self.booleanregistrationinformation_set
        elif self.type == self.INTEGER_FIELD:
            value_set = self.integerregistrationinformation_set

        try:
            field_value = value_set.get(registration=registration)
        except BooleanRegistrationInformation.DoesNotExist:
            field_value = BooleanRegistrationInformation()
        except TextRegistrationInformation.DoesNotExist:
            field_value = TextRegistrationInformation()
        except IntegerRegistrationInformation.DoesNotExist:
            field_value = IntegerRegistrationInformation()

        field_value.registration = registration
        field_value.field = self
        field_value.value = value
        field_value.save()

    def __str__(self):
        return "{} ({})".format(self.name, dict(self.FIELD_TYPES)[self.type])

    class Meta:
        order_with_respect_to = 'event'


Thom Wiggers's avatar
Thom Wiggers committed
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
class AbstractRegistrationInformation(models.Model):
    """Abstract to contain common things for registration information"""

    registration = models.ForeignKey(Registration, models.CASCADE)
    field = models.ForeignKey(RegistrationInformationField, models.CASCADE)
    changed = models.DateTimeField(_('last changed'), auto_now=True)

    def __str__(self):
        return '{} - {}: {}'.format(self.registration, self.field, self.value)

    class Meta:
        abstract = True


class BooleanRegistrationInformation(AbstractRegistrationInformation):
Joren Vrancken's avatar
Joren Vrancken committed
592
    """Checkbox information filled in by members when registering"""
Thom Wiggers's avatar
Thom Wiggers committed
593
594
595
596
597

    value = models.BooleanField()


class TextRegistrationInformation(AbstractRegistrationInformation):
Joren Vrancken's avatar
Joren Vrancken committed
598
    """Checkbox information filled in by members when registering"""
Thom Wiggers's avatar
Thom Wiggers committed
599
600
601
602
    value = models.TextField()


class IntegerRegistrationInformation(AbstractRegistrationInformation):
Joren Vrancken's avatar
Joren Vrancken committed
603
    """Checkbox information filled in by members when registering"""
Thom Wiggers's avatar
Thom Wiggers committed
604
    value = models.IntegerField()
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625


class FeedToken(models.Model):
    """Used to personalize the ical Feed"""

    member = models.OneToOneField('members.Member', models.CASCADE)
    token = models.CharField(max_length=32, editable=False)

    def save(self, *args, **kwargs):
        self.token = get_random_string(32)
        super().save(*args, **kwargs)

    @staticmethod
    def get_member(token):
        try:
            return FeedToken.objects.get(token=token).member
        except FeedToken.DoesNotExist:
            return None

    def __str__(self):
        return '{} ({})'.format(self.member.get_full_name(), self.token)