Projet

Général

Profil

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

Valentin Deniaud, 12 janvier 2021 16:33

Télécharger (35,3 ko)

Voir les différences:

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

 .../migrations/0071_auto_20210112_1543.py     |  47 +++++++
 chrono/agendas/models.py                      | 128 +++++++++++++++++-
 chrono/api/urls.py                            |   4 +-
 chrono/api/views.py                           |  53 +++++++-
 chrono/manager/forms.py                       |   3 +-
 .../chrono/manager_agenda_event_fragment.html |   8 +-
 chrono/manager/urls.py                        |   5 +
 chrono/manager/views.py                       |  35 ++++-
 tests/test_agendas.py                         |  91 +++++++++++++
 tests/test_api.py                             |  97 ++++++++++++-
 tests/test_import_export.py                   |  32 +++++
 tests/test_manager.py                         |  38 +++++-
 12 files changed, 528 insertions(+), 13 deletions(-)
 create mode 100644 chrono/agendas/migrations/0071_auto_20210112_1543.py
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
-