0003-add-support-for-recurring-events-41663.patch
chrono/agendas/migrations/0074_auto_20210126_1410.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.18 on 2021-01-26 13:10 |
|
3 |
from __future__ import unicode_literals |
|
4 | ||
5 |
from django.db import migrations, models |
|
6 |
import django.db.models.deletion |
|
7 |
import jsonfield.fields |
|
8 | ||
9 | ||
10 |
class Migration(migrations.Migration): |
|
11 | ||
12 |
dependencies = [ |
|
13 |
('agendas', '0073_auto_20210125_1800'), |
|
14 |
] |
|
15 | ||
16 |
operations = [ |
|
17 |
migrations.AddField( |
|
18 |
model_name='event', |
|
19 |
name='primary_event', |
|
20 |
field=models.ForeignKey( |
|
21 |
null=True, |
|
22 |
on_delete=django.db.models.deletion.CASCADE, |
|
23 |
related_name='recurrences', |
|
24 |
to='agendas.Event', |
|
25 |
), |
|
26 |
), |
|
27 |
migrations.AddField( |
|
28 |
model_name='event', |
|
29 |
name='recurrence_rule', |
|
30 |
field=jsonfield.fields.JSONField(null=True, verbose_name='Recurrence rule'), |
|
31 |
), |
|
32 |
migrations.AddField( |
|
33 |
model_name='event', |
|
34 |
name='repeat', |
|
35 |
field=models.CharField( |
|
36 |
blank=True, |
|
37 |
choices=[ |
|
38 |
('daily', 'Daily'), |
|
39 |
('weekly', 'Weekly'), |
|
40 |
('2-weeks', 'Once every two weeks'), |
|
41 |
('weekdays', 'Every weekdays (Monday to Friday)'), |
|
42 |
], |
|
43 |
max_length=16, |
|
44 |
verbose_name='Repeat', |
|
45 |
), |
|
46 |
), |
|
47 |
] |
chrono/agendas/models.py | ||
---|---|---|
25 | 25 | |
26 | 26 |
import requests |
27 | 27 |
import vobject |
28 |
from dateutil.rrule import rrule, rruleset, DAILY, WEEKLY |
|
29 |
from dateutil.relativedelta import relativedelta |
|
28 | 30 | |
29 | 31 |
import django |
30 | 32 |
from django.conf import settings |
... | ... | |
43 | 45 |
from django.utils.formats import date_format |
44 | 46 |
from django.utils.module_loading import import_string |
45 | 47 |
from django.utils.text import slugify |
46 |
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware |
|
48 |
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware, utc
|
|
47 | 49 |
from django.utils.translation import ugettext_lazy as _, ugettext, ungettext |
48 | 50 | |
49 | 51 |
from jsonfield import JSONField |
... | ... | |
317 | 319 |
if self.kind == 'events': |
318 | 320 |
agenda['default_view'] = self.default_view |
319 | 321 |
agenda['booking_form_url'] = self.booking_form_url |
320 |
agenda['events'] = [x.export_json() for x in self.event_set.all()]
|
|
322 |
agenda['events'] = [x.export_json() for x in self.event_set.filter(primary_event__isnull=True)]
|
|
321 | 323 |
if hasattr(self, 'notifications_settings'): |
322 | 324 |
agenda['notifications_settings'] = self.notifications_settings.export_json() |
323 | 325 |
elif self.kind == 'meetings': |
... | ... | |
495 | 497 |
self, |
496 | 498 |
prefetched_queryset=False, |
497 | 499 |
annotate_queryset=False, |
498 |
include_full=True,
|
|
500 |
exclude_full=False,
|
|
499 | 501 |
min_start=None, |
500 | 502 |
max_start=None, |
501 | 503 |
): |
... | ... | |
504 | 506 |
if prefetched_queryset: |
505 | 507 |
entries = self.prefetched_events |
506 | 508 |
else: |
507 |
entries = self.event_set.filter(cancelled=False) |
|
509 |
# recurring events are never opened |
|
510 |
entries = self.event_set.filter(recurrence_rule__isnull=True) |
|
511 |
# exclude canceled events except for event recurrences |
|
512 |
entries = entries.filter(Q(cancelled=False) | Q(primary_event__isnull=False)) |
|
508 | 513 |
# we never want to allow booking for past events. |
509 | 514 |
entries = entries.filter(start_datetime__gte=localtime(now())) |
510 | 515 |
# exclude non published events |
511 | 516 |
entries = entries.filter( |
512 | 517 |
Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()) |
513 | 518 |
) |
514 |
if not include_full:
|
|
519 |
if exclude_full:
|
|
515 | 520 |
entries = entries.filter(Q(full=False) | Q(primary_event__isnull=False)) |
516 | 521 | |
517 | 522 |
if self.minimal_booking_delay: |
... | ... | |
541 | 546 |
if annotate_queryset and not prefetched_queryset: |
542 | 547 |
entries = Event.annotate_queryset(entries) |
543 | 548 | |
549 |
if max_start: |
|
550 |
entries = self.add_event_recurrences( |
|
551 |
entries, |
|
552 |
min_start or localtime(now()), |
|
553 |
max_start, |
|
554 |
exclude_full=exclude_full, |
|
555 |
prefetched_queryset=prefetched_queryset, |
|
556 |
) |
|
557 | ||
544 | 558 |
return entries |
545 | 559 | |
560 |
def add_event_recurrences( |
|
561 |
self, |
|
562 |
events, |
|
563 |
min_start, |
|
564 |
max_start, |
|
565 |
exclude_full=False, |
|
566 |
exclude_cancelled=True, |
|
567 |
prefetched_queryset=False, |
|
568 |
): |
|
569 |
excluded_datetimes = [make_naive(event.start_datetime) for event in events] |
|
570 | ||
571 |
events = [ |
|
572 |
e for e in events if not (e.cancelled and exclude_cancelled) and not (exclude_full and e.full) |
|
573 |
] |
|
574 | ||
575 |
if prefetched_queryset: |
|
576 |
recurring_events = self.prefetched_recurring_events |
|
577 |
else: |
|
578 |
recurring_events = self.event_set.filter(recurrence_rule__isnull=False) |
|
579 |
for event in recurring_events: |
|
580 |
events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes)) |
|
581 | ||
582 |
events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) |
|
583 |
return events |
|
584 | ||
546 | 585 |
def get_booking_form_url(self): |
547 | 586 |
if not self.booking_form_url: |
548 | 587 |
return |
... | ... | |
867 | 906 | |
868 | 907 | |
869 | 908 |
class Event(models.Model): |
909 |
REPEAT_CHOICES = [ |
|
910 |
('daily', _('Daily')), |
|
911 |
('weekly', _('Weekly')), |
|
912 |
('2-weeks', _('Once every two weeks')), |
|
913 |
('weekdays', _('Every weekdays (Monday to Friday)')), |
|
914 |
] |
|
915 | ||
870 | 916 |
agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE) |
871 | 917 |
start_datetime = models.DateTimeField(_('Date/time')) |
918 |
repeat = models.CharField(_('Repeat'), max_length=16, blank=True, choices=REPEAT_CHOICES) |
|
919 |
recurrence_rule = JSONField(_('Recurrence rule'), null=True) |
|
920 |
primary_event = models.ForeignKey('self', null=True, on_delete=models.CASCADE, related_name='recurrences') |
|
872 | 921 |
duration = models.PositiveIntegerField(_('Duration (in minutes)'), default=None, null=True, blank=True) |
873 | 922 |
publication_date = models.DateField(_('Publication date'), blank=True, null=True) |
874 | 923 |
places = models.PositiveIntegerField(_('Places')) |
... | ... | |
921 | 970 |
self.check_full() |
922 | 971 |
if not self.slug: |
923 | 972 |
self.slug = generate_slug(self, seen_slugs=seen_slugs, agenda=self.agenda) |
973 |
self.recurrence_rule = self.get_recurrence_rule() |
|
924 | 974 |
return super(Event, self).save(*args, **kwargs) |
925 | 975 | |
926 | 976 |
@property |
... | ... | |
1043 | 1093 |
return { |
1044 | 1094 |
'start_datetime': make_naive(self.start_datetime).strftime('%Y-%m-%d %H:%M:%S'), |
1045 | 1095 |
'publication_date': self.publication_date.strftime('%Y-%m-%d') if self.publication_date else None, |
1096 |
'repeat': self.repeat, |
|
1097 |
'recurrence_rule': self.recurrence_rule, |
|
1046 | 1098 |
'places': self.places, |
1047 | 1099 |
'waiting_list_places': self.waiting_list_places, |
1048 | 1100 |
'label': self.label, |
... | ... | |
1076 | 1128 |
self.cancelled = True |
1077 | 1129 |
self.save() |
1078 | 1130 | |
1131 |
def get_or_create_event_recurrence(self, start_datetime): |
|
1132 |
events = self.get_recurrences(start_datetime, start_datetime) |
|
1133 | ||
1134 |
if len(events) == 0: |
|
1135 |
raise ValueError('No event recurrence found for specified datetime.') |
|
1136 |
elif len(events) > 1: # should not happen |
|
1137 |
raise ValueError('Multiple events found for specified datetime.') |
|
1138 | ||
1139 |
event = events[0] |
|
1140 |
event.slug = event.slug.replace(':', '-') |
|
1141 | ||
1142 |
with transaction.atomic(): |
|
1143 |
try: |
|
1144 |
return Event.objects.get(agenda=self.agenda, slug=event.slug) |
|
1145 |
except Event.DoesNotExist: |
|
1146 |
event.save() |
|
1147 |
return event |
|
1148 | ||
1149 |
def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None): |
|
1150 |
recurrences = [] |
|
1151 |
rrule_set = rruleset() |
|
1152 |
# do not generate recurrences for existing events |
|
1153 |
rrule_set._exdate = excluded_datetimes or [] |
|
1154 | ||
1155 |
event_base = Event( |
|
1156 |
agenda=self.agenda, |
|
1157 |
primary_event=self, |
|
1158 |
slug=self.slug, |
|
1159 |
duration=self.duration, |
|
1160 |
places=self.places, |
|
1161 |
waiting_list_places=self.waiting_list_places, |
|
1162 |
label=self.label, |
|
1163 |
description=self.description, |
|
1164 |
pricing=self.pricing, |
|
1165 |
url=self.url, |
|
1166 |
) |
|
1167 | ||
1168 |
# remove pytz info because dateutil doesn't support DST changes |
|
1169 |
min_datetime = make_naive(min_datetime) |
|
1170 |
max_datetime = make_naive(max_datetime) |
|
1171 |
rrule_set.rrule(rrule(dtstart=make_naive(self.start_datetime), **self.recurrence_rule)) |
|
1172 | ||
1173 |
for start_datetime in rrule_set.between(min_datetime, max_datetime, inc=True): |
|
1174 |
event = copy.copy(event_base) |
|
1175 |
# add timezone back |
|
1176 |
aware_start_datetime = make_aware(start_datetime) |
|
1177 |
event.slug = '%s:%s' % (event.slug, aware_start_datetime.strftime('%Y-%m-%d-%H%M')) |
|
1178 |
event.start_datetime = aware_start_datetime.astimezone(utc) |
|
1179 |
recurrences.append(event) |
|
1180 | ||
1181 |
return recurrences |
|
1182 | ||
1183 |
def get_recurrence_display(self): |
|
1184 |
repeat = str(self.get_repeat_display()) |
|
1185 |
time = date_format(localtime(self.start_datetime), 'TIME_FORMAT') |
|
1186 |
if self.repeat in ('weekly', '2-weeks'): |
|
1187 |
day = date_format(localtime(self.start_datetime), 'l') |
|
1188 |
return _('%(every_x_days)s on %(day)s at %(time)s') % { |
|
1189 |
'every_x_days': repeat, |
|
1190 |
'day': day, |
|
1191 |
'time': time, |
|
1192 |
} |
|
1193 |
else: |
|
1194 |
return _('%(every_x_days)s at %(time)s') % {'every_x_days': repeat, 'time': time} |
|
1195 | ||
1196 |
def get_recurrence_rule(self): |
|
1197 |
rrule = {} |
|
1198 |
if self.repeat == 'daily': |
|
1199 |
rrule['freq'] = DAILY |
|
1200 |
elif self.repeat == 'weekly': |
|
1201 |
rrule['freq'] = WEEKLY |
|
1202 |
rrule['byweekday'] = [self.start_datetime.weekday()] |
|
1203 |
elif self.repeat == '2-weeks': |
|
1204 |
rrule['freq'] = WEEKLY |
|
1205 |
rrule['byweekday'] = [self.start_datetime.weekday()] |
|
1206 |
rrule['interval'] = 2 |
|
1207 |
elif self.repeat == 'weekdays': |
|
1208 |
rrule['freq'] = WEEKLY |
|
1209 |
rrule['byweekday'] = [i for i in range(5)] |
|
1210 |
else: |
|
1211 |
return None |
|
1212 |
return rrule |
|
1213 | ||
1079 | 1214 | |
1080 | 1215 |
class BookingColor(models.Model): |
1081 | 1216 |
COLOR_COUNT = 8 |
chrono/api/urls.py | ||
---|---|---|
29 | 29 |
), |
30 | 30 |
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'), |
31 | 31 |
url( |
32 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w-]+)/$', |
|
32 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w:-]+)/$',
|
|
33 | 33 |
views.slot_status, |
34 | 34 |
name='api-event-status', |
35 | 35 |
), |
36 | 36 |
url( |
37 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/bookings/(?P<event_identifier>[\w-]+)/$', |
|
37 |
r'^agenda/(?P<agenda_identifier>[\w-]+)/bookings/(?P<event_identifier>[\w:-]+)/$',
|
|
38 | 38 |
views.slot_bookings, |
39 | 39 |
name='api-event-bookings', |
40 | 40 |
), |
chrono/api/views.py | ||
---|---|---|
326 | 326 |
) |
327 | 327 |
} |
328 | 328 |
if check_events: |
329 |
agenda_detail['opened_events_available'] = bool(agenda.get_open_events(include_full=False))
|
|
329 |
agenda_detail['opened_events_available'] = bool(agenda.get_open_events(exclude_full=True))
|
|
330 | 330 |
elif agenda.accept_meetings(): |
331 | 331 |
agenda_detail['api'] = { |
332 | 332 |
'meetings_url': request.build_absolute_uri( |
... | ... | |
365 | 365 | |
366 | 366 |
def get_event_detail(request, event, agenda=None): |
367 | 367 |
agenda = agenda or event.agenda |
368 |
if event.label and event.primary_event is not None: |
|
369 |
event.label = '%s (%s)' % ( |
|
370 |
event.label, |
|
371 |
date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'), |
|
372 |
) |
|
368 | 373 |
return { |
369 | 374 |
'id': event.slug, |
370 | 375 |
'slug': event.slug, # kept for compatibility |
... | ... | |
416 | 421 |
} |
417 | 422 | |
418 | 423 | |
424 |
def get_event_recurrence(agenda, event_identifier): |
|
425 |
event_slug, datetime_str = event_identifier.split(':') |
|
426 |
try: |
|
427 |
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')) |
|
428 |
except ValueError: |
|
429 |
raise APIError( |
|
430 |
_('bad datetime format: %s') % datetime_str, |
|
431 |
err_class='bad datetime format: %s' % datetime_str, |
|
432 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
433 |
) |
|
434 |
try: |
|
435 |
event = agenda.event_set.get(slug=event_slug) |
|
436 |
except Event.DoesNotExist: |
|
437 |
raise APIError( |
|
438 |
_('unknown recurring event slug: %s') % event_slug, |
|
439 |
err_class='unknown recurring event slug:' % event_slug, |
|
440 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
441 |
) |
|
442 |
try: |
|
443 |
return event.get_or_create_event_recurrence(start_datetime) |
|
444 |
except ValueError: |
|
445 |
raise APIError( |
|
446 |
_('invalid datetime for event %s') % event_identifier, |
|
447 |
err_class='invalid datetime for event %s' % event_identifier, |
|
448 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
449 |
) |
|
450 | ||
451 | ||
419 | 452 |
def get_resources_from_request(request, agenda): |
420 | 453 |
if agenda.kind != 'meetings' or 'resources' not in request.GET: |
421 | 454 |
return [] |
... | ... | |
451 | 484 |
cancelled=False, |
452 | 485 |
start_datetime__gte=localtime(now()), |
453 | 486 |
).order_by() |
487 |
recurring_event_queryset = Event.objects.filter(recurrence_rule__isnull=False) |
|
454 | 488 |
agendas_queryset = agendas_queryset.filter(kind='events').prefetch_related( |
455 | 489 |
Prefetch( |
456 | 490 |
'event_set', |
457 | 491 |
queryset=event_queryset, |
458 | 492 |
to_attr='prefetched_events', |
459 |
) |
|
493 |
), |
|
494 |
Prefetch( |
|
495 |
'event_set', |
|
496 |
queryset=recurring_event_queryset, |
|
497 |
to_attr='prefetched_recurring_events', |
|
498 |
), |
|
460 | 499 |
) |
461 | 500 | |
462 | 501 |
agendas = [] |
... | ... | |
1026 | 1065 |
event.resources.add(*resources) |
1027 | 1066 |
events.append(event) |
1028 | 1067 |
else: |
1068 |
# convert event recurrence identifiers to real event slugs |
|
1069 |
for i, slot in enumerate(slots.copy()): |
|
1070 |
if not ':' in slot: |
|
1071 |
continue |
|
1072 |
event = get_event_recurrence(agenda, slot) |
|
1073 |
slots[i] = event.slug |
|
1074 | ||
1029 | 1075 |
try: |
1030 | 1076 |
events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime') |
1031 | 1077 |
except ValueError: |
... | ... | |
1548 | 1594 |
agenda = Agenda.objects.get(pk=agenda_identifier, kind='events') |
1549 | 1595 |
except (ValueError, Agenda.DoesNotExist): |
1550 | 1596 |
raise Http404() |
1597 |
if ':' in event_identifier: |
|
1598 |
return get_event_recurrence(agenda, event_identifier) |
|
1551 | 1599 |
try: |
1552 | 1600 |
return agenda.event_set.get(slug=event_identifier) |
1553 | 1601 |
except Event.DoesNotExist: |
... | ... | |
1573 | 1621 |
permission_classes = (permissions.IsAuthenticated,) |
1574 | 1622 | |
1575 | 1623 |
def get_object(self, agenda_identifier, event_identifier): |
1624 |
if ':' in event_identifier: |
|
1625 |
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') |
|
1626 |
return get_event_recurrence(agenda, event_identifier) |
|
1576 | 1627 |
return get_object_or_404( |
1577 | 1628 |
Event, slug=event_identifier, agenda__slug=agenda_identifier, agenda__kind='events' |
1578 | 1629 |
) |
chrono/manager/forms.py | ||
---|---|---|
160 | 160 |
} |
161 | 161 |
fields = [ |
162 | 162 |
'start_datetime', |
163 |
'repeat', |
|
163 | 164 |
'duration', |
164 | 165 |
'publication_date', |
165 | 166 |
'places', |
... | ... | |
182 | 183 |
} |
183 | 184 |
fields = [ |
184 | 185 |
'start_datetime', |
186 |
'repeat', |
|
185 | 187 |
'duration', |
186 | 188 |
'publication_date', |
187 | 189 |
'places', |
... | ... | |
479 | 481 |
raise ValidationError(_('Invalid file format. (duration, line %d)') % (i + 1)) |
480 | 482 | |
481 | 483 |
try: |
482 |
event.full_clean(exclude=['desk', 'meeting_type']) |
|
484 |
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
|
|
483 | 485 |
except ValidationError as e: |
484 | 486 |
errors = [_('Invalid file format:\n')] |
485 | 487 |
for label, field_errors in e.message_dict.items(): |
chrono/manager/templates/chrono/manager_agenda_event_fragment.html | ||
---|---|---|
8 | 8 |
{% elif event.waiting_list_places %} |
9 | 9 |
data-total="{{ event.waiting_list_places }}" data-booked="{{ event.waiting_list_count }}" |
10 | 10 |
{% endif %} |
11 |
><a href="{% if settings_view %}{% url 'chrono-manager-event-edit' pk=agenda.pk event_pk=event.pk %}?next=settings{% else %}{% url 'chrono-manager-event-view' pk=agenda.pk event_pk=event.pk %}{% endif %}">
|
|
11 |
><a href="{% if settings_view %}{% url 'chrono-manager-event-edit' pk=agenda.pk event_pk=event.pk %}?next=settings{% elif event.pk %}{% url 'chrono-manager-event-view' pk=agenda.pk event_pk=event.pk %}{% else %}{% url 'chrono-manager-event-create-recurrence' pk=agenda.pk event_identifier=event.slug %}{% endif %}">
|
|
12 | 12 |
{% if event.cancellation_status %} |
13 | 13 |
<span class="tag">{{ event.cancellation_status }}</span> |
14 | 14 |
{% elif event.main_list_full %} |
... | ... | |
20 | 20 |
{% else %} |
21 | 21 |
{% if event.label %}{{ event.label }} / {% endif %} |
22 | 22 |
{% endif %} |
23 |
{% if not event.repeat %} |
|
23 | 24 |
{{ event.start_datetime }} |
25 |
{% else %} |
|
26 |
{{ event.get_recurrence_display }} |
|
27 |
{% endif %} |
|
24 | 28 |
{% if not settings_view %} |
25 | 29 |
{% if event.places or event.waiting_list_places %}-{% endif %} |
26 | 30 |
{% if event.places %} |
chrono/manager/urls.py | ||
---|---|---|
170 | 170 |
views.event_cancellation_report_list, |
171 | 171 |
name='chrono-manager-event-cancellation-report-list', |
172 | 172 |
), |
173 |
url( |
|
174 |
r'^agendas/(?P<pk>\d+)/create_event_recurrence/(?P<event_identifier>[\w:-]+)/$', |
|
175 |
views.event_create_recurrence, |
|
176 |
name='chrono-manager-event-create-recurrence', |
|
177 |
), |
|
173 | 178 |
url( |
174 | 179 |
r'^agendas/(?P<pk>\d+)/add-resource/$', |
175 | 180 |
views.agenda_add_resource, |
chrono/manager/views.py | ||
---|---|---|
49 | 49 |
TemplateView, |
50 | 50 |
DayArchiveView, |
51 | 51 |
MonthArchiveView, |
52 |
RedirectView, |
|
52 | 53 |
View, |
53 | 54 |
) |
54 | 55 | |
... | ... | |
893 | 894 | |
894 | 895 |
def get_queryset(self): |
895 | 896 |
if self.agenda.kind == 'events': |
896 |
queryset = self.agenda.event_set.all()
|
|
897 |
queryset = self.agenda.event_set.filter(recurrence_rule__isnull=True)
|
|
897 | 898 |
else: |
898 | 899 |
self.agenda.prefetch_desks_and_exceptions() |
899 | 900 |
if self.agenda.kind == 'meetings': |
... | ... | |
1031 | 1032 |
return qs |
1032 | 1033 |
return Event.annotate_queryset(qs).order_by('start_datetime', 'label') |
1033 | 1034 | |
1035 |
def get_dated_items(self): |
|
1036 |
date_list, object_list, extra_context = super().get_dated_items() |
|
1037 |
if self.agenda.kind == 'events': |
|
1038 |
min_start = make_aware(datetime.datetime.combine(extra_context['month'], datetime.time(0, 0))) |
|
1039 |
max_start = make_aware( |
|
1040 |
datetime.datetime.combine(extra_context['next_month'], datetime.time(0, 0)) |
|
1041 |
) |
|
1042 |
object_list = self.agenda.add_event_recurrences( |
|
1043 |
object_list, min_start, max_start, exclude_cancelled=False |
|
1044 |
) |
|
1045 |
return date_list, object_list, extra_context |
|
1046 | ||
1034 | 1047 |
def get_template_names(self): |
1035 | 1048 |
if self.agenda.kind == 'virtual': |
1036 | 1049 |
return ['chrono/manager_meetings_agenda_month_view.html'] |
... | ... | |
1385 | 1398 |
return context |
1386 | 1399 | |
1387 | 1400 |
def get_events(self): |
1388 |
qs = self.agenda.event_set.all()
|
|
1401 |
qs = self.agenda.event_set.filter(primary_event__isnull=True)
|
|
1389 | 1402 |
return Event.annotate_queryset(qs) |
1390 | 1403 | |
1391 | 1404 |
def get_template_names(self): |
... | ... | |
2345 | 2358 |
event_cancellation_report_list = EventCancellationReportListView.as_view() |
2346 | 2359 | |
2347 | 2360 | |
2361 |
class EventCreateRecurrenceView(ManagedAgendaMixin, RedirectView): |
|
2362 |
def get_redirect_url(self, *args, **kwargs): |
|
2363 |
event_slug, datetime_str = kwargs['event_identifier'].split(':') |
|
2364 |
try: |
|
2365 |
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')) |
|
2366 |
except ValueError: |
|
2367 |
raise Http404() |
|
2368 |
event = self.agenda.event_set.get(slug=event_slug) |
|
2369 |
try: |
|
2370 |
event_recurrence = event.get_or_create_event_recurrence(start_datetime) |
|
2371 |
except ValueError: |
|
2372 |
raise Http404() |
|
2373 |
return event_recurrence.get_absolute_view_url() |
|
2374 | ||
2375 | ||
2376 |
event_create_recurrence = EventCreateRecurrenceView.as_view() |
|
2377 | ||
2378 | ||
2348 | 2379 |
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView): |
2349 | 2380 |
model = TimePeriodExceptionSource |
2350 | 2381 |
tests/test_agendas.py | ||
---|---|---|
1660 | 1660 |
booking.refresh_from_db() |
1661 | 1661 |
assert booking.label == 'hop' |
1662 | 1662 |
assert not booking.anonymization_datetime |
1663 | ||
1664 | ||
1665 |
def test_recurring_events(freezer): |
|
1666 |
freezer.move_to('2021-01-06 12:00') # Wednesday |
|
1667 |
agenda = Agenda.objects.create(label='Agenda', kind='events') |
|
1668 |
event = Event.objects.create( |
|
1669 |
agenda=agenda, |
|
1670 |
start_datetime=now(), |
|
1671 |
repeat='weekly', |
|
1672 |
label='Event', |
|
1673 |
places=10, |
|
1674 |
waiting_list_places=10, |
|
1675 |
duration=10, |
|
1676 |
description='Description', |
|
1677 |
url='https://example.com', |
|
1678 |
pricing='10€', |
|
1679 |
) |
|
1680 |
event.refresh_from_db() |
|
1681 | ||
1682 |
recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=15)) |
|
1683 |
assert len(recurrences) == 3 |
|
1684 | ||
1685 |
first_event = recurrences[0] |
|
1686 |
assert first_event.slug == event.slug + ':2021-01-06-1300' |
|
1687 | ||
1688 |
event_json = event.export_json() |
|
1689 |
first_event_json = first_event.export_json() |
|
1690 |
different_fields = ['slug', 'repeat', 'recurrence_rule'] |
|
1691 |
assert all(first_event_json[k] == event_json[k] for k in event_json if k not in different_fields) |
|
1692 | ||
1693 |
second_event = recurrences[1] |
|
1694 |
assert second_event.start_datetime == first_event.start_datetime + datetime.timedelta(days=7) |
|
1695 |
assert second_event.start_datetime.weekday() == first_event.start_datetime.weekday() |
|
1696 |
assert second_event.slug == 'event:2021-01-13-1300' |
|
1697 | ||
1698 |
different_fields = ['slug', 'start_datetime'] |
|
1699 |
second_event_json = second_event.export_json() |
|
1700 |
assert all(first_event_json[k] == second_event_json[k] for k in event_json if k not in different_fields) |
|
1701 | ||
1702 |
new_recurrences = event.get_recurrences( |
|
1703 |
localtime() + datetime.timedelta(days=15), |
|
1704 |
localtime() + datetime.timedelta(days=30), |
|
1705 |
) |
|
1706 |
assert len(recurrences) == 3 |
|
1707 |
assert new_recurrences[0].start_datetime == recurrences[-1].start_datetime + datetime.timedelta(days=7) |
|
1708 | ||
1709 | ||
1710 |
def test_recurring_events_dst(freezer, settings): |
|
1711 |
freezer.move_to('2020-10-24 12:00') |
|
1712 |
settings.TIME_ZONE = 'Europe/Brussels' |
|
1713 |
agenda = Agenda.objects.create(label='Agenda', kind='events') |
|
1714 |
event = Event.objects.create(agenda=agenda, start_datetime=now(), repeat='weekly', places=5) |
|
1715 |
event.refresh_from_db() |
|
1716 |
dt = localtime() |
|
1717 |
recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8)) |
|
1718 |
event_before_dst, event_after_dst = recurrences |
|
1719 |
assert event_before_dst.start_datetime.hour + 1 == event_after_dst.start_datetime.hour |
|
1720 |
assert event_before_dst.slug == 'agenda-event:2020-10-24-1400' |
|
1721 |
assert event_after_dst.slug == 'agenda-event:2020-10-31-1400' |
|
1722 | ||
1723 |
freezer.move_to('2020-11-24 12:00') |
|
1724 |
new_recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8)) |
|
1725 |
new_event_before_dst, new_event_after_dst = new_recurrences |
|
1726 |
assert event_before_dst.start_datetime == new_event_before_dst.start_datetime |
|
1727 |
assert event_after_dst.start_datetime == new_event_after_dst.start_datetime |
|
1728 |
assert event_before_dst.slug == new_event_before_dst.slug |
|
1729 |
assert event_after_dst.slug == new_event_after_dst.slug |
|
1730 | ||
1731 | ||
1732 |
def test_recurring_events_repeat(freezer): |
|
1733 |
freezer.move_to('2021-01-06 12:00') # Wednesday |
|
1734 |
agenda = Agenda.objects.create(label='Agenda', kind='events') |
|
1735 |
event = Event.objects.create( |
|
1736 |
agenda=agenda, |
|
1737 |
start_datetime=now(), |
|
1738 |
repeat='daily', |
|
1739 |
places=5, |
|
1740 |
) |
|
1741 |
event.refresh_from_db() |
|
1742 |
start_datetime = localtime(event.start_datetime) |
|
1743 | ||
1744 |
freezer.move_to('2021-01-06 12:01') # recurrence on same day should not be returned |
|
1745 |
recurrences = event.get_recurrences( |
|
1746 |
localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) |
|
1747 |
) |
|
1748 |
assert len(recurrences) == 6 |
|
1749 |
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) |
|
1750 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) |
|
1751 |
for i in range(len(recurrences) - 1): |
|
1752 |
assert recurrences[i].start_datetime + datetime.timedelta(days=1) == recurrences[i + 1].start_datetime |
|
1753 | ||
1754 |
event.repeat = 'weekdays' |
|
1755 |
event.save() |
|
1756 |
recurrences = event.get_recurrences( |
|
1757 |
localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) |
|
1758 |
) |
|
1759 |
assert len(recurrences) == 4 |
|
1760 |
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) |
|
1761 |
assert recurrences[1].start_datetime == start_datetime + datetime.timedelta(days=5) |
|
1762 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) |
|
1763 | ||
1764 |
event.repeat = '2-weeks' |
|
1765 |
event.save() |
|
1766 |
recurrences = event.get_recurrences( |
|
1767 |
localtime() + datetime.timedelta(days=3), localtime() + datetime.timedelta(days=45) |
|
1768 |
) |
|
1769 |
assert len(recurrences) == 3 |
|
1770 |
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=14) |
|
1771 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=14) * len(recurrences) |
|
1772 |
for i in range(len(recurrences) - 1): |
|
1773 |
assert ( |
|
1774 |
recurrences[i].start_datetime + datetime.timedelta(days=14) == recurrences[i + 1].start_datetime |
|
1775 |
) |
tests/test_api.py | ||
---|---|---|
282 | 282 |
resp = app.get('/api/agenda/', params={'with_open_events': '1'}) |
283 | 283 |
assert len(resp.json['data']) == 0 |
284 | 284 | |
285 |
# event recurrences are available |
|
286 |
event = Event.objects.create( |
|
287 |
start_datetime=now(), |
|
288 |
places=10, |
|
289 |
agenda=event_agenda, |
|
290 |
repeat='daily', |
|
291 |
) |
|
292 |
assert len(event_agenda.get_open_events()) == 2 |
|
293 |
resp = app.get('/api/agenda/', params={'with_open_events': '1'}) |
|
294 |
assert len(resp.json['data']) == 1 |
|
295 | ||
285 | 296 |
with CaptureQueriesContext(connection) as ctx: |
286 | 297 |
resp = app.get('/api/agenda/', params={'with_open_events': '1'}) |
287 |
assert len(ctx.captured_queries) == 3
|
|
298 |
assert len(ctx.captured_queries) == 4
|
|
288 | 299 | |
289 | 300 | |
290 | 301 |
def test_agendas_meetingtypes_api(app, some_data, meetings_agenda): |
... | ... | |
1677 | 1688 | |
1678 | 1689 |
with CaptureQueriesContext(connection) as ctx: |
1679 | 1690 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
1680 |
assert len(ctx.captured_queries) == 3
|
|
1691 |
assert len(ctx.captured_queries) == 4
|
|
1681 | 1692 |
assert resp.json['data'][-1]['places']['total'] == 20 |
1682 | 1693 |
assert resp.json['data'][-1]['places']['available'] == 20 |
1683 | 1694 |
assert resp.json['data'][-1]['places']['reserved'] == 0 |
... | ... | |
5206 | 5217 |
'bookable_datetimes_number_available': 0, |
5207 | 5218 |
'bookable_datetimes_first': None, |
5208 | 5219 |
} |
5220 | ||
5221 | ||
5222 |
def test_recurring_events_api(app, user, freezer): |
|
5223 |
freezer.move_to('2021-01-12 12:05') # Tuesday |
|
5224 |
agenda = Agenda.objects.create( |
|
5225 |
label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30 |
|
5226 |
) |
|
5227 |
event = Event.objects.create( |
|
5228 |
slug='abc', label='Test', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda |
|
5229 |
) |
|
5230 | ||
5231 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
5232 |
data = resp.json['data'] |
|
5233 |
assert len(data) == 4 |
|
5234 |
assert data[0]['id'] == 'abc:2021-01-19-1305' |
|
5235 |
assert data[0]['datetime'] == '2021-01-19 13:05:00' |
|
5236 |
assert data[0]['text'] == 'Test (Jan. 19, 2021, 1:05 p.m.)' |
|
5237 |
assert data[3]['id'] == 'abc:2021-02-09-1305' |
|
5238 |
assert Event.objects.count() == 1 |
|
5239 | ||
5240 |
fillslot_url = data[0]['api']['fillslot_url'] |
|
5241 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
5242 | ||
5243 |
# book first event |
|
5244 |
resp = app.post(fillslot_url) |
|
5245 |
assert resp.json['err'] == 0 |
|
5246 |
assert Event.objects.count() == 2 |
|
5247 |
event = Booking.objects.get(pk=resp.json['booking_id']).event |
|
5248 |
assert event.slug == 'abc-2021-01-19-1305' |
|
5249 | ||
5250 |
# first event is now a real event in datetimes |
|
5251 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
5252 |
data = resp.json['data'] |
|
5253 |
assert len(data) == 4 |
|
5254 |
assert data[0]['id'] == event.slug |
|
5255 |
new_fillslot_url = data[0]['api']['fillslot_url'] |
|
5256 | ||
5257 |
# booking again with both old and new urls works |
|
5258 |
resp = app.post(fillslot_url) |
|
5259 |
assert resp.json['err'] == 0 |
|
5260 |
resp = app.post(new_fillslot_url) |
|
5261 |
assert resp.json['err'] == 0 |
|
5262 |
assert Event.objects.count() == 2 |
|
5263 |
assert event.booking_set.count() == 3 |
|
5264 | ||
5265 |
# status and bookings api also create a real event |
|
5266 |
status_url = data[1]['api']['status_url'] |
|
5267 |
resp = app.get(status_url) |
|
5268 |
assert resp.json['places']['total'] == 5 |
|
5269 |
assert Event.objects.count() == 3 |
|
5270 | ||
5271 |
bookings_url = data[2]['api']['bookings_url'] |
|
5272 |
resp = app.get(bookings_url, params={'user_external_id': '42'}) |
|
5273 |
assert resp.json['data'] == [] |
|
5274 |
assert Event.objects.count() == 4 |
|
5275 | ||
5276 |
# cancelled recurrences do not appear |
|
5277 |
event.cancel() |
|
5278 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
5279 |
assert len(resp.json['data']) == 3 |
|
5280 |
assert resp.json['data'][0]['id'] == 'abc-2021-01-26-1305' |
|
5281 | ||
5282 | ||
5283 |
def test_recurring_events_api_various_times(app, user, mock_now): |
|
5284 |
agenda = Agenda.objects.create( |
|
5285 |
label='Foo bar', kind='events', minimal_booking_delay=0, maximal_booking_delay=30 |
|
5286 |
) |
|
5287 |
event = Event.objects.create( |
|
5288 |
slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda |
|
5289 |
) |
|
5290 |
event.refresh_from_db() |
|
5291 | ||
5292 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
5293 |
assert len(resp.json['data']) == 5 |
|
5294 |
fillslot_url = resp.json['data'][0]['api']['fillslot_url'] |
|
5295 | ||
5296 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
5297 |
resp = app.post(fillslot_url) |
|
5298 |
assert resp.json['err'] == 0 |
|
5299 | ||
5300 |
new_event = Booking.objects.get(pk=resp.json['booking_id']).event |
|
5301 |
assert event.start_datetime == new_event.start_datetime |
tests/test_import_export.py | ||
---|---|---|
12 | 12 |
import tempfile |
13 | 13 | |
14 | 14 |
import pytest |
15 |
from dateutil.rrule import DAILY |
|
16 | ||
15 | 17 |
from django.contrib.auth.models import Group |
16 | 18 |
from django.core.management import call_command, CommandError |
17 | 19 |
from django.test import override_settings |
... | ... | |
189 | 191 |
assert first_imported_event.publication_date == datetime.date(2020, 5, 11) |
190 | 192 | |
191 | 193 | |
194 |
def test_import_export_recurring_event(app, freezer): |
|
195 |
freezer.move_to('2021-01-12 12:10') |
|
196 |
agenda = Agenda.objects.create(label='Foo Bar', kind='events') |
|
197 |
event = Event.objects.create( |
|
198 |
agenda=agenda, |
|
199 |
start_datetime=now(), |
|
200 |
repeat='daily', |
|
201 |
places=10, |
|
202 |
) |
|
203 |
event.refresh_from_db() |
|
204 |
event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=3)) |
|
205 |
assert Event.objects.count() == 2 |
|
206 | ||
207 |
output = get_output_of_command('export_site') |
|
208 |
assert len(json.loads(output)['agendas']) == 1 |
|
209 |
import_site(data={}, clean=True) |
|
210 | ||
211 |
with tempfile.NamedTemporaryFile() as f: |
|
212 |
f.write(force_bytes(output)) |
|
213 |
f.flush() |
|
214 |
call_command('import_site', f.name) |
|
215 | ||
216 |
assert Agenda.objects.count() == 1 |
|
217 |
assert Event.objects.count() == 1 |
|
218 |
event = Agenda.objects.get(label='Foo Bar').event_set.first() |
|
219 |
assert event.primary_event is None |
|
220 |
assert event.repeat == 'daily' |
|
221 |
assert event.recurrence_rule == {'freq': DAILY} |
|
222 | ||
223 | ||
192 | 224 |
def test_import_export_permissions(app): |
193 | 225 |
meetings_agenda = Agenda.objects.create(label='Foo Bar', kind='meetings') |
194 | 226 |
group1 = Group.objects.create(name=u'gé1') |
tests/test_manager.py | ||
---|---|---|
3165 | 3165 |
app.get( |
3166 | 3166 |
'/manage/agendas/%s/%s/%s/' % (agenda.id, event.start_datetime.year, event.start_datetime.month) |
3167 | 3167 |
) |
3168 |
assert len(ctx.captured_queries) == 6
|
|
3168 |
assert len(ctx.captured_queries) == 7
|
|
3169 | 3169 | |
3170 | 3170 |
# current month still doesn't have events |
3171 | 3171 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month)) |
3172 | 3172 |
assert "This month doesn't have any event configured." in resp.text |
3173 | 3173 | |
3174 |
# add recurring event on every Wednesday |
|
3175 |
start_datetime = localtime().replace(day=7, month=10, year=2020) |
|
3176 |
event = Event.objects.create( |
|
3177 |
label='abc', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly' |
|
3178 |
) |
|
3179 | ||
3180 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 10)) |
|
3181 |
assert len(resp.pyquery.find('.event-info')) == 4 |
|
3182 |
assert 'abc' in resp.pyquery.find('.event-info')[0].text |
|
3183 | ||
3184 |
# 12/2020 has 5 Wednesday |
|
3185 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 12)) |
|
3186 |
assert len(resp.pyquery.find('.event-info')) == 5 |
|
3187 | ||
3188 |
# trying to access event recurrence creates it |
|
3189 |
event_count = Event.objects.count() |
|
3190 |
time = localtime(event.start_datetime).strftime('%H%M') |
|
3191 |
resp = resp.click(href='abc:2020-12-02-%s' % time) |
|
3192 |
assert Event.objects.count() == event_count + 1 |
|
3193 | ||
3194 |
Event.objects.get(slug='abc-2020-12-02-%s' % time).cancel() |
|
3195 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 12)) |
|
3196 |
assert 'Cancelled' in resp.text |
|
3197 | ||
3198 |
bad_event_url = '/manage/agendas/%s/create_event_recurrence/abc:2020-10-8-1130/' % agenda.id |
|
3199 |
resp = app.get(bad_event_url, status=404) |
|
3200 | ||
3174 | 3201 | |
3175 | 3202 |
def test_agenda_open_events_view(app, admin_user, manager_user): |
3176 | 3203 |
agenda = Agenda.objects.create( |
... | ... | |
3219 | 3246 |
publication_date=today - datetime.timedelta(days=1), |
3220 | 3247 |
places=42, |
3221 | 3248 |
) |
3249 |
# weekly recurring event, first recurrence is in the past but second is in range |
|
3250 |
event = Event.objects.create( |
|
3251 |
label='event G', |
|
3252 |
start_datetime=now() - datetime.timedelta(days=3), |
|
3253 |
places=10, |
|
3254 |
agenda=agenda, |
|
3255 |
repeat='weekly', |
|
3256 |
) |
|
3222 | 3257 |
resp = app.get('/manage/agendas/%s/events/open/' % agenda.pk) |
3223 | 3258 |
assert 'event A' not in resp.text |
3224 | 3259 |
assert 'event B' not in resp.text |
... | ... | |
3226 | 3261 |
assert 'event D' in resp.text |
3227 | 3262 |
assert 'event E' not in resp.text |
3228 | 3263 |
assert 'event F' in resp.text |
3264 |
assert resp.text.count('event G') == 1 |
|
3229 | 3265 | |
3230 | 3266 |
# event the first of February in 2 years at 00:00, already publicated |
3231 | 3267 |
# and another event in January in 2 years |
3232 |
- |