Projet

Général

Profil

0003-add-support-for-recurring-events-41663.patch

Valentin Deniaud, 28 janvier 2021 12:38

Télécharger (37,9 ko)

Voir les différences:

Subject: [PATCH 3/5] add support for recurring events (#41663)

 .../migrations/0074_auto_20210126_1410.py     |  47 ++++++
 chrono/agendas/models.py                      | 145 +++++++++++++++++-
 chrono/api/urls.py                            |   4 +-
 chrono/api/views.py                           |  55 ++++++-
 chrono/manager/forms.py                       |   4 +-
 .../chrono/manager_agenda_event_fragment.html |   6 +-
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  35 ++++-
 tests/test_agendas.py                         | 113 ++++++++++++++
 tests/test_api.py                             |  97 +++++++++++-
 tests/test_import_export.py                   |  32 ++++
 tests/test_manager.py                         |  38 ++++-
 12 files changed, 565 insertions(+), 16 deletions(-)
 create mode 100644 chrono/agendas/migrations/0074_auto_20210126_1410.py
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
-