0004-add-support-for-recurring-events-41663.patch
chrono/agendas/migrations/0071_auto_20210112_1543.py | ||
---|---|---|
1 |
# -*- coding: utf-8 -*- |
|
2 |
# Generated by Django 1.11.29 on 2021-01-12 14:43 |
|
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', '0070_auto_20201202_1834'), |
|
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, 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': |
... | ... | |
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 |
... | ... | |
537 | 542 |
if annotate_queryset: |
538 | 543 |
entries = Event.annotate_queryset(entries) |
539 | 544 | |
545 |
if max_start: |
|
546 |
entries = self.add_event_recurrences( |
|
547 |
entries, |
|
548 |
min_start, |
|
549 |
max_start, |
|
550 |
include_full=include_full, |
|
551 |
prefetched_queryset=prefetched_queryset, |
|
552 |
) |
|
553 | ||
540 | 554 |
return entries |
541 | 555 | |
556 |
def add_event_recurrences( |
|
557 |
self, |
|
558 |
events, |
|
559 |
min_start, |
|
560 |
max_start, |
|
561 |
include_full=True, |
|
562 |
include_cancelled=False, |
|
563 |
prefetched_queryset=False, |
|
564 |
): |
|
565 |
events = list(events) |
|
566 |
existing_datetimes = {} |
|
567 |
for i, event in enumerate(events.copy()): |
|
568 |
if event.primary_event is not None: |
|
569 |
existing_datetimes.setdefault(event.primary_event.slug, set()).add(event.start_datetime) |
|
570 |
if not include_cancelled and event.cancelled or not include_full and event.full: |
|
571 |
del events[i] |
|
572 | ||
573 |
if prefetched_queryset: |
|
574 |
recurring_events = self.prefetched_recurring_events |
|
575 |
else: |
|
576 |
recurring_events = self.event_set.filter(recurrence_rule__isnull=False) |
|
577 |
for event in recurring_events: |
|
578 |
excluded_datetimes = existing_datetimes.get(event.slug, {}) |
|
579 |
events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes)) |
|
580 | ||
581 |
events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering]) |
|
582 |
return events |
|
583 | ||
542 | 584 |
def get_booking_form_url(self): |
543 | 585 |
if not self.booking_form_url: |
544 | 586 |
return |
... | ... | |
863 | 905 | |
864 | 906 | |
865 | 907 |
class Event(models.Model): |
908 |
REPEAT_CHOICES = [ |
|
909 |
('daily', _('Daily')), |
|
910 |
('weekly', _('Weekly')), |
|
911 |
('2-weeks', _('Once every two weeks')), |
|
912 |
('weekdays', _('Every weekdays (Monday to Friday)')), |
|
913 |
] |
|
914 | ||
866 | 915 |
agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE) |
867 | 916 |
start_datetime = models.DateTimeField(_('Date/time')) |
917 |
repeat = models.CharField(_('Repeat'), max_length=16, blank=True, choices=REPEAT_CHOICES) |
|
918 |
recurrence_rule = JSONField(_('Recurrence rule'), null=True) |
|
919 |
primary_event = models.ForeignKey('self', null=True, on_delete=models.CASCADE, related_name='recurrences') |
|
868 | 920 |
duration = models.PositiveIntegerField(_('Duration (in minutes)'), default=None, null=True, blank=True) |
869 | 921 |
publication_date = models.DateField(_('Publication date'), blank=True, null=True) |
870 | 922 |
places = models.PositiveIntegerField(_('Places')) |
... | ... | |
917 | 969 |
self.check_full() |
918 | 970 |
if not self.slug: |
919 | 971 |
self.slug = generate_slug(self, seen_slugs=seen_slugs, agenda=self.agenda) |
972 |
if self.repeat: |
|
973 |
self.recurrence_rule = self.get_recurrence_rule() |
|
920 | 974 |
return super(Event, self).save(*args, **kwargs) |
921 | 975 | |
922 | 976 |
@property |
... | ... | |
936 | 990 |
self.almost_full = bool(self.booked_places >= 0.9 * self.places) |
937 | 991 | |
938 | 992 |
def in_bookable_period(self): |
993 |
if self.recurrence_rule is not None: |
|
994 |
return True |
|
939 | 995 |
if self.publication_date and localtime(now()).date() < self.publication_date: |
940 | 996 |
return False |
941 | 997 |
today = localtime(now()).date() |
... | ... | |
1039 | 1095 |
return { |
1040 | 1096 |
'start_datetime': make_naive(self.start_datetime).strftime('%Y-%m-%d %H:%M:%S'), |
1041 | 1097 |
'publication_date': self.publication_date.strftime('%Y-%m-%d') if self.publication_date else None, |
1098 |
'repeat': self.repeat, |
|
1099 |
'recurrence_rule': self.recurrence_rule, |
|
1042 | 1100 |
'places': self.places, |
1043 | 1101 |
'waiting_list_places': self.waiting_list_places, |
1044 | 1102 |
'label': self.label, |
... | ... | |
1072 | 1130 |
self.cancelled = True |
1073 | 1131 |
self.save() |
1074 | 1132 | |
1133 |
def get_or_create_event_recurrence(self, start_datetime): |
|
1134 |
events = self.get_recurrences(start_datetime, start_datetime) |
|
1135 | ||
1136 |
if len(events) == 0: |
|
1137 |
raise ValueError('No event recurrence found for specified datetime.') |
|
1138 |
elif len(events) > 1: # should not happen |
|
1139 |
raise ValueError('Multiple events found for specified datetime.') |
|
1140 | ||
1141 |
event = events[0] |
|
1142 |
event.slug = event.slug.replace(':', '-') |
|
1143 | ||
1144 |
with transaction.atomic(): |
|
1145 |
try: |
|
1146 |
return Event.objects.get(agenda=self.agenda, slug=event.slug) |
|
1147 |
except Event.DoesNotExist: |
|
1148 |
event.save() |
|
1149 |
return event |
|
1150 | ||
1151 |
def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None): |
|
1152 |
recurrences = [] |
|
1153 |
excluded_datetimes = 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 |
datetimes = rrule(dtstart=localtime(self.start_datetime), cache=True, **self.recurrence_rule) |
|
1169 |
for start_datetime in datetimes.between(min_datetime, max_datetime, inc=True): |
|
1170 |
if start_datetime in excluded_datetimes: |
|
1171 |
continue |
|
1172 |
event = copy.copy(event_base) |
|
1173 |
event.slug = '%s:%s' % (event.slug, start_datetime.strftime('%Y-%m-%d-%H%M')) |
|
1174 |
event.start_datetime = start_datetime.astimezone(utc) |
|
1175 |
recurrences.append(event) |
|
1176 | ||
1177 |
return recurrences |
|
1178 | ||
1179 |
def get_recurrence_rule(self): |
|
1180 |
rrule = {} |
|
1181 |
if self.repeat == 'daily': |
|
1182 |
rrule['freq'] = DAILY |
|
1183 |
elif self.repeat == 'weekly': |
|
1184 |
rrule['freq'] = WEEKLY |
|
1185 |
rrule['byweekday'] = [self.start_datetime.weekday()] |
|
1186 |
elif self.repeat == '2-weeks': |
|
1187 |
rrule['freq'] = WEEKLY |
|
1188 |
rrule['byweekday'] = [self.start_datetime.weekday()] |
|
1189 |
rrule['interval'] = 2 |
|
1190 |
elif self.repeat == 'weekdays': |
|
1191 |
rrule['freq'] = WEEKLY |
|
1192 |
rrule['byweekday'] = [i for i in range(5)] |
|
1193 |
else: |
|
1194 |
return None |
|
1195 |
return rrule |
|
1196 | ||
1075 | 1197 | |
1076 | 1198 |
class BookingColor(models.Model): |
1077 | 1199 |
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 | ||
---|---|---|
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 |
... | ... | |
398 | 403 |
} |
399 | 404 | |
400 | 405 | |
406 |
def get_event_recurrence(agenda, event_identifier): |
|
407 |
event_slug, datetime_str = event_identifier.split(':') |
|
408 |
try: |
|
409 |
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')) |
|
410 |
except ValueError: |
|
411 |
raise APIError( |
|
412 |
_('bad datetime format: %s') % datetime_str, |
|
413 |
err_class='bad datetime format: %s' % datetime_str, |
|
414 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
415 |
) |
|
416 |
try: |
|
417 |
event = agenda.event_set.get(slug=event_slug) |
|
418 |
except Event.DoesNotExist: |
|
419 |
raise APIError( |
|
420 |
_('unknown recurring event slug: %s') % event_slug, |
|
421 |
err_class='unknown recurring event slug:' % event_slug, |
|
422 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
423 |
) |
|
424 |
try: |
|
425 |
return event.get_or_create_event_recurrence(start_datetime) |
|
426 |
except ValueError: |
|
427 |
raise APIError( |
|
428 |
_('invalid datetime for event %s') % event_identifier, |
|
429 |
err_class='invalid datetime for event %s' % event_identifier, |
|
430 |
http_status=status.HTTP_400_BAD_REQUEST, |
|
431 |
) |
|
432 | ||
433 | ||
401 | 434 |
def get_resources_from_request(request, agenda): |
402 | 435 |
if agenda.kind != 'meetings' or 'resources' not in request.GET: |
403 | 436 |
return [] |
... | ... | |
433 | 466 |
cancelled=False, |
434 | 467 |
start_datetime__gte=localtime(now()), |
435 | 468 |
).order_by() |
469 |
recurring_event_queryset = Event.objects.filter(recurrence_rule__isnull=False) |
|
436 | 470 |
agendas_queryset = agendas_queryset.filter(kind='events').prefetch_related( |
437 | 471 |
Prefetch( |
438 | 472 |
'event_set', |
439 | 473 |
queryset=event_queryset, |
440 | 474 |
to_attr='prefetched_events', |
441 |
) |
|
475 |
), |
|
476 |
Prefetch( |
|
477 |
'event_set', |
|
478 |
queryset=recurring_event_queryset, |
|
479 |
to_attr='prefetched_recurring_events', |
|
480 |
), |
|
442 | 481 |
) |
443 | 482 | |
444 | 483 |
agendas = [] |
... | ... | |
997 | 1036 |
event.resources.add(*resources) |
998 | 1037 |
events.append(event) |
999 | 1038 |
else: |
1039 |
# convert event recurrence identifiers to real event slugs |
|
1040 |
for i, slot in enumerate(slots.copy()): |
|
1041 |
if not ':' in slot: |
|
1042 |
continue |
|
1043 |
event = get_event_recurrence(agenda, slot) |
|
1044 |
slots[i] = event.slug |
|
1045 | ||
1000 | 1046 |
try: |
1001 | 1047 |
events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime') |
1002 | 1048 |
except ValueError: |
... | ... | |
1440 | 1486 |
agenda = Agenda.objects.get(pk=agenda_identifier, kind='events') |
1441 | 1487 |
except (ValueError, Agenda.DoesNotExist): |
1442 | 1488 |
raise Http404() |
1489 |
if ':' in event_identifier: |
|
1490 |
return get_event_recurrence(agenda, event_identifier) |
|
1443 | 1491 |
try: |
1444 | 1492 |
return agenda.event_set.get(slug=event_identifier) |
1445 | 1493 |
except Event.DoesNotExist: |
... | ... | |
1465 | 1513 |
permission_classes = (permissions.IsAuthenticated,) |
1466 | 1514 | |
1467 | 1515 |
def get_object(self, agenda_identifier, event_identifier): |
1516 |
if ':' in event_identifier: |
|
1517 |
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events') |
|
1518 |
return get_event_recurrence(agenda, event_identifier) |
|
1468 | 1519 |
return get_object_or_404( |
1469 | 1520 |
Event, slug=event_identifier, agenda__slug=agenda_identifier, agenda__kind='events' |
1470 | 1521 |
) |
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', |
... | ... | |
462 | 463 |
raise ValidationError(_('Invalid file format. (duration, line %d)') % (i + 1)) |
463 | 464 | |
464 | 465 |
try: |
465 |
event.full_clean(exclude=['desk', 'meeting_type']) |
|
466 |
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
|
|
466 | 467 |
except ValidationError as e: |
467 | 468 |
errors = [_('Invalid file format:\n')] |
468 | 469 |
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 |
{% blocktrans with every_x_days=event.get_repeat_display day=event.start_datetime|date:"l" time=event.start_datetime|time %} |
|
27 |
{{ every_x_days }} on {{ day }} at {{ time }} |
|
28 |
{% endblocktrans %} |
|
29 |
{% endif %} |
|
24 | 30 |
{% if not settings_view %} |
25 | 31 |
{% if event.places or event.waiting_list_places %}-{% endif %} |
26 | 32 |
{% 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, include_cancelled=True |
|
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): |
... | ... | |
2341 | 2354 |
event_cancellation_report_list = EventCancellationReportListView.as_view() |
2342 | 2355 | |
2343 | 2356 | |
2357 |
class EventCreateRecurrenceView(ManagedAgendaMixin, RedirectView): |
|
2358 |
def get_redirect_url(self, *args, **kwargs): |
|
2359 |
event_slug, datetime_str = kwargs['event_identifier'].split(':') |
|
2360 |
try: |
|
2361 |
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')) |
|
2362 |
except ValueError: |
|
2363 |
raise Http404() |
|
2364 |
event = self.agenda.event_set.get(slug=event_slug) |
|
2365 |
try: |
|
2366 |
event_recurrence = event.get_or_create_event_recurrence(start_datetime) |
|
2367 |
except ValueError: |
|
2368 |
raise Http404() |
|
2369 |
return event_recurrence.get_absolute_view_url() |
|
2370 | ||
2371 | ||
2372 |
event_create_recurrence = EventCreateRecurrenceView.as_view() |
|
2373 | ||
2374 | ||
2344 | 2375 |
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView): |
2345 | 2376 |
model = TimePeriodExceptionSource |
2346 | 2377 |
tests/test_agendas.py | ||
---|---|---|
1653 | 1653 |
booking.refresh_from_db() |
1654 | 1654 |
assert booking.label == 'hop' |
1655 | 1655 |
assert not booking.anonymization_datetime |
1656 | ||
1657 | ||
1658 |
def test_recurring_events(freezer): |
|
1659 |
freezer.move_to('2021-01-06 12:00') # Wednesday |
|
1660 |
agenda = Agenda.objects.create(label='Agenda', kind='events') |
|
1661 |
event = Event.objects.create( |
|
1662 |
agenda=agenda, |
|
1663 |
start_datetime=now(), |
|
1664 |
repeat='weekly', |
|
1665 |
label='Event', |
|
1666 |
places=10, |
|
1667 |
waiting_list_places=10, |
|
1668 |
duration=10, |
|
1669 |
description='Description', |
|
1670 |
url='https://example.com', |
|
1671 |
pricing='10€', |
|
1672 |
) |
|
1673 |
event.refresh_from_db() |
|
1674 | ||
1675 |
recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=15)) |
|
1676 |
assert len(recurrences) == 3 |
|
1677 | ||
1678 |
first_event = recurrences[0] |
|
1679 |
assert first_event.slug == event.slug + ':2021-01-06-1300' |
|
1680 | ||
1681 |
event_json = event.export_json() |
|
1682 |
first_event_json = first_event.export_json() |
|
1683 |
different_fields = ['slug', 'repeat', 'recurrence_rule'] |
|
1684 |
assert all(first_event_json[k] == event_json[k] for k in event_json if k not in different_fields) |
|
1685 | ||
1686 |
second_event = recurrences[1] |
|
1687 |
assert second_event.start_datetime == first_event.start_datetime + datetime.timedelta(days=7) |
|
1688 |
assert second_event.start_datetime.weekday() == first_event.start_datetime.weekday() |
|
1689 |
assert second_event.slug == 'event:2021-01-13-1300' |
|
1690 | ||
1691 |
different_fields = ['slug', 'start_datetime'] |
|
1692 |
second_event_json = second_event.export_json() |
|
1693 |
assert all(first_event_json[k] == second_event_json[k] for k in event_json if k not in different_fields) |
|
1694 | ||
1695 |
new_recurrences = event.get_recurrences( |
|
1696 |
localtime() + datetime.timedelta(days=15), |
|
1697 |
localtime() + datetime.timedelta(days=30), |
|
1698 |
) |
|
1699 |
assert len(recurrences) == 3 |
|
1700 |
assert new_recurrences[0].start_datetime == recurrences[-1].start_datetime + datetime.timedelta(days=7) |
|
1701 | ||
1702 | ||
1703 |
def test_recurring_events_repeat(freezer): |
|
1704 |
freezer.move_to('2021-01-06 12:00') # Wednesday |
|
1705 |
agenda = Agenda.objects.create(label='Agenda', kind='events') |
|
1706 |
event = Event.objects.create( |
|
1707 |
agenda=agenda, |
|
1708 |
start_datetime=now(), |
|
1709 |
repeat='daily', |
|
1710 |
places=5, |
|
1711 |
) |
|
1712 |
event.refresh_from_db() |
|
1713 |
start_datetime = localtime(event.start_datetime) |
|
1714 | ||
1715 |
freezer.move_to('2021-01-06 12:01') # recurrence on same day should not be returned |
|
1716 |
recurrences = event.get_recurrences( |
|
1717 |
localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) |
|
1718 |
) |
|
1719 |
assert len(recurrences) == 6 |
|
1720 |
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) |
|
1721 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) |
|
1722 |
for i in range(len(recurrences) - 1): |
|
1723 |
assert recurrences[i].start_datetime + datetime.timedelta(days=1) == recurrences[i + 1].start_datetime |
|
1724 | ||
1725 |
event.repeat = 'weekdays' |
|
1726 |
event.save() |
|
1727 |
recurrences = event.get_recurrences( |
|
1728 |
localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7) |
|
1729 |
) |
|
1730 |
assert len(recurrences) == 4 |
|
1731 |
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2) |
|
1732 |
assert recurrences[1].start_datetime == start_datetime + datetime.timedelta(days=5) |
|
1733 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7) |
|
1734 | ||
1735 |
event.repeat = '2-weeks' |
|
1736 |
event.save() |
|
1737 |
recurrences = event.get_recurrences( |
|
1738 |
localtime() + datetime.timedelta(days=3), localtime() + datetime.timedelta(days=45) |
|
1739 |
) |
|
1740 |
assert len(recurrences) == 3 |
|
1741 |
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=14) |
|
1742 |
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=14) * len(recurrences) |
|
1743 |
for i in range(len(recurrences) - 1): |
|
1744 |
assert ( |
|
1745 |
recurrences[i].start_datetime + datetime.timedelta(days=14) == recurrences[i + 1].start_datetime |
|
1746 |
) |
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): |
... | ... | |
1671 | 1682 | |
1672 | 1683 |
with CaptureQueriesContext(connection) as ctx: |
1673 | 1684 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
1674 |
assert len(ctx.captured_queries) == 3
|
|
1685 |
assert len(ctx.captured_queries) == 4
|
|
1675 | 1686 |
assert resp.json['data'][-1]['places']['total'] == 20 |
1676 | 1687 |
assert resp.json['data'][-1]['places']['available'] == 20 |
1677 | 1688 |
assert resp.json['data'][-1]['places']['reserved'] == 0 |
... | ... | |
4817 | 4828 |
virtual_agenda.save() |
4818 | 4829 |
resp = app.get(virtual_api_url, params=params) |
4819 | 4830 |
assert len(resp.json['data']) == 16 |
4831 | ||
4832 | ||
4833 |
def test_recurring_events_api(app, user, freezer): |
|
4834 |
freezer.move_to('2021-01-12 12:05') # Tuesday |
|
4835 |
agenda = Agenda.objects.create( |
|
4836 |
label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30 |
|
4837 |
) |
|
4838 |
event = Event.objects.create( |
|
4839 |
slug='abc', label='Test', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda |
|
4840 |
) |
|
4841 | ||
4842 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
4843 |
data = resp.json['data'] |
|
4844 |
assert len(data) == 4 |
|
4845 |
assert data[0]['id'] == 'abc:2021-01-19-1305' |
|
4846 |
assert data[0]['datetime'] == '2021-01-19 13:05:00' |
|
4847 |
assert data[0]['text'] == 'Test (Jan. 19, 2021, 1:05 p.m.)' |
|
4848 |
assert data[3]['id'] == 'abc:2021-02-09-1305' |
|
4849 |
assert Event.objects.count() == 1 |
|
4850 | ||
4851 |
fillslot_url = data[0]['api']['fillslot_url'] |
|
4852 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
4853 | ||
4854 |
# book first event |
|
4855 |
resp = app.post(fillslot_url) |
|
4856 |
assert resp.json['err'] == 0 |
|
4857 |
assert Event.objects.count() == 2 |
|
4858 |
event = Booking.objects.get(pk=resp.json['booking_id']).event |
|
4859 |
assert event.slug == 'abc-2021-01-19-1305' |
|
4860 | ||
4861 |
# first event is now a real event in datetimes |
|
4862 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
4863 |
data = resp.json['data'] |
|
4864 |
assert len(data) == 4 |
|
4865 |
assert data[0]['id'] == event.slug |
|
4866 |
new_fillslot_url = data[0]['api']['fillslot_url'] |
|
4867 | ||
4868 |
# booking again with both old and new urls works |
|
4869 |
resp = app.post(fillslot_url) |
|
4870 |
assert resp.json['err'] == 0 |
|
4871 |
resp = app.post(new_fillslot_url) |
|
4872 |
assert resp.json['err'] == 0 |
|
4873 |
assert Event.objects.count() == 2 |
|
4874 |
assert event.booking_set.count() == 3 |
|
4875 | ||
4876 |
# status and bookings api also create a real event |
|
4877 |
status_url = data[1]['api']['status_url'] |
|
4878 |
resp = app.get(status_url) |
|
4879 |
assert resp.json['places']['total'] == 5 |
|
4880 |
assert Event.objects.count() == 3 |
|
4881 | ||
4882 |
bookings_url = data[2]['api']['bookings_url'] |
|
4883 |
resp = app.get(bookings_url, params={'user_external_id': '42'}) |
|
4884 |
assert resp.json['data'] == [] |
|
4885 |
assert Event.objects.count() == 4 |
|
4886 | ||
4887 |
# cancelled recurrences do not appear |
|
4888 |
event.cancel() |
|
4889 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
4890 |
assert len(resp.json['data']) == 3 |
|
4891 |
assert resp.json['data'][0]['id'] == 'abc-2021-01-26-1305' |
|
4892 | ||
4893 | ||
4894 |
def test_recurring_events_api_various_times(app, user, mock_now): |
|
4895 |
agenda = Agenda.objects.create( |
|
4896 |
label='Foo bar', kind='events', minimal_booking_delay=0, maximal_booking_delay=30 |
|
4897 |
) |
|
4898 |
event = Event.objects.create( |
|
4899 |
slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda |
|
4900 |
) |
|
4901 |
event.refresh_from_db() |
|
4902 | ||
4903 |
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) |
|
4904 |
assert len(resp.json['data']) == 5 |
|
4905 |
fillslot_url = resp.json['data'][0]['api']['fillslot_url'] |
|
4906 | ||
4907 |
app.authorization = ('Basic', ('john.doe', 'password')) |
|
4908 |
resp = app.post(fillslot_url) |
|
4909 |
assert resp.json['err'] == 0 |
|
4910 | ||
4911 |
new_event = Booking.objects.get(pk=resp.json['booking_id']).event |
|
4912 |
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=6, month=1, year=2021) |
|
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, 2021, 1)) |
|
3181 |
assert len(resp.pyquery.find('.event-info')) == 4 |
|
3182 |
assert 'abc' in resp.pyquery.find('.event-info')[0].text |
|
3183 | ||
3184 |
# 03/2021 has 5 Wednesday |
|
3185 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 3)) |
|
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 = event.start_datetime.strftime('%H%M') |
|
3191 |
resp = resp.click(href='abc:2021-03-10-%s' % time) |
|
3192 |
assert Event.objects.count() == event_count + 1 |
|
3193 | ||
3194 |
Event.objects.get(slug='abc-2021-03-10-%s' % time).cancel() |
|
3195 |
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 3)) |
|
3196 |
assert 'Cancelled' in resp.text |
|
3197 | ||
3198 |
bad_event_url = '/manage/agendas/%s/create_event_recurrence/abc:2021-03-11-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 |
# not enough permissions |
3231 | 3267 |
app.reset() |
3232 |
- |