Projet

Général

Profil

0001-manager-add-more-granular-control-over-event-recurre.patch

Valentin Deniaud, 22 avril 2021 17:52

Télécharger (42,1 ko)

Voir les différences:

Subject: [PATCH] manager: add more granular control over event recurrence
 (#50560)

 .../migrations/0079_auto_20210421_1556.py     |  80 ++++++++++++
 chrono/agendas/models.py                      | 118 +++++++++++-------
 chrono/api/views.py                           |   2 +-
 chrono/manager/forms.py                       |  62 +++++++--
 chrono/manager/static/css/style.scss          |   4 +
 .../chrono/manager_agenda_event_fragment.html |   2 +-
 .../templates/chrono/manager_event_form.html  |  19 ++-
 .../templates/chrono/widgets/weekdays.html    |  10 ++
 chrono/manager/views.py                       |   6 +-
 chrono/manager/widgets.py                     |  13 +-
 tests/manager/test_all.py                     |  28 +++--
 tests/manager/test_event.py                   |  38 +++---
 tests/test_agendas.py                         |  89 +++++++++----
 tests/test_api.py                             |  25 ++--
 tests/test_import_export.py                   |   8 +-
 15 files changed, 387 insertions(+), 117 deletions(-)
 create mode 100644 chrono/agendas/migrations/0079_auto_20210421_1556.py
 create mode 100644 chrono/manager/templates/chrono/widgets/weekdays.html
chrono/agendas/migrations/0079_auto_20210421_1556.py
1
# Generated by Django 2.2.19 on 2021-04-21 13:56
2

  
3
import django.contrib.postgres.fields
4
from django.db import migrations, models
5
from dateutil.rrule import DAILY, WEEKLY
6

  
7

  
8
def migrate_recurrence_fields(apps, schema_editor):
9
    Event = apps.get_model('agendas', 'Event')
10

  
11
    for event in Event.objects.filter(recurrence_rule__isnull=False):
12
        if event.recurrence_rule['freq'] == DAILY:
13
            event.recurrence_days = list(range(7))
14
        elif event.recurrence_rule['freq'] == WEEKLY:
15
            event.recurrence_days = event.recurrence_rule['byweekday']
16
        event.recurrence_week_interval = event.recurrence_rule.get('interval', 1)
17
        event.save()
18

  
19

  
20
def reverse_migrate_recurrence_fields(apps, schema_editor):
21
    Event = apps.get_model('agendas', 'Event')
22

  
23
    for event in Event.objects.filter(recurrence_days__isnull=False):
24
        rrule = {}
25
        if event.recurrence_days == list(range(7)):
26
            event.repeat = 'daily'
27
            rrule['freq'] = DAILY
28
        else:
29
            rrule['freq'] = WEEKLY
30
            rrule['byweekday'] = event.recurrence_days
31
            if event.recurrence_days == list(range(5)):
32
                event.repeat = 'weekdays'
33
            elif event.recurrence_week_interval == 2:
34
                event.repeat = '2-weeks'
35
                rrule['interval'] = 2
36
            else:
37
                event.repeat = 'weekly'
38
        event.recurrence_rule = rrule
39
        event.save()
40

  
41

  
42
class Migration(migrations.Migration):
43

  
44
    dependencies = [
45
        ('agendas', '0078_absence_reasons'),
46
    ]
47

  
48
    operations = [
49
        migrations.AddField(
50
            model_name='event',
51
            name='recurrence_days',
52
            field=django.contrib.postgres.fields.ArrayField(
53
                base_field=models.IntegerField(
54
                    choices=[(0, 'Mo'), (1, 'Tu'), (2, 'We'), (3, 'Th'), (4, 'Fr'), (5, 'Sa'), (6, 'Su')]
55
                ),
56
                blank=True,
57
                null=True,
58
                size=None,
59
                verbose_name='Recurrence days',
60
            ),
61
        ),
62
        migrations.AddField(
63
            model_name='event',
64
            name='recurrence_week_interval',
65
            field=models.IntegerField(
66
                choices=[(1, 'Every week'), (2, 'Every two weeks'), (3, 'Every three weeks')],
67
                default=1,
68
                verbose_name='Repeat',
69
            ),
70
        ),
71
        migrations.RunPython(migrate_recurrence_fields, reverse_migrate_recurrence_fields),
72
        migrations.RemoveField(
73
            model_name='event',
74
            name='recurrence_rule',
75
        ),
76
        migrations.RemoveField(
77
            model_name='event',
78
            name='repeat',
79
        ),
80
    ]
chrono/agendas/models.py
26 26

  
27 27
import requests
28 28
import vobject
29
from dateutil.rrule import rrule, rruleset, DAILY, WEEKLY
29
from dateutil.rrule import rrule, rruleset, WEEKLY
30 30

  
31 31
import django
32 32
from django.conf import settings
......
576 576
            entries = self.prefetched_events
577 577
        else:
578 578
            # recurring events are never opened
579
            entries = self.event_set.filter(recurrence_rule__isnull=True)
579
            entries = self.event_set.filter(recurrence_days__isnull=True)
580 580
            # exclude canceled events except for event recurrences
581 581
            entries = entries.filter(Q(cancelled=False) | Q(primary_event__isnull=False))
582 582
            # we never want to allow booking for past events.
......
644 644
        if prefetched_queryset:
645 645
            recurring_events = self.prefetched_recurring_events
646 646
        else:
647
            recurring_events = self.event_set.filter(recurrence_rule__isnull=False)
647
            recurring_events = self.event_set.filter(recurrence_days__isnull=False)
648 648
        for event in recurring_events:
649 649
            events.extend(
650 650
                event.get_recurrences(
......
1076 1076

  
1077 1077

  
1078 1078
class Event(models.Model):
1079
    REPEAT_CHOICES = [
1080
        ('daily', _('Daily')),
1081
        ('weekly', _('Weekly')),
1082
        ('2-weeks', _('Once every two weeks')),
1083
        ('weekdays', _('Every weekdays (Monday to Friday)')),
1079
    WEEKDAY_CHOICES = [
1080
        (0, _('Mo')),
1081
        (1, _('Tu')),
1082
        (2, _('We')),
1083
        (3, _('Th')),
1084
        (4, _('Fr')),
1085
        (5, _('Sa')),
1086
        (6, _('Su')),
1087
    ]
1088

  
1089
    INTERVAL_CHOICES = [
1090
        (1, 'Every week'),
1091
        (2, 'Every two weeks'),
1092
        (3, 'Every three weeks'),
1084 1093
    ]
1085 1094

  
1086 1095
    agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE)
1087 1096
    start_datetime = models.DateTimeField(_('Date/time'))
1088
    repeat = models.CharField(_('Repeat'), max_length=16, blank=True, choices=REPEAT_CHOICES)
1089
    recurrence_rule = JSONField(_('Recurrence rule'), null=True)
1097
    recurrence_days = ArrayField(
1098
        models.IntegerField(choices=WEEKDAY_CHOICES),
1099
        verbose_name=_('Recurrence days'),
1100
        blank=True,
1101
        null=True,
1102
    )
1103
    recurrence_week_interval = models.IntegerField(_('Repeat'), choices=INTERVAL_CHOICES, default=1)
1090 1104
    recurrence_end_date = models.DateField(_('Recurrence end date'), null=True, blank=True)
1091 1105
    primary_event = models.ForeignKey('self', null=True, on_delete=models.CASCADE, related_name='recurrences')
1092 1106
    duration = models.PositiveIntegerField(_('Duration (in minutes)'), default=None, null=True, blank=True)
......
1141 1155
        self.check_full()
1142 1156
        if not self.slug:
1143 1157
            self.slug = generate_slug(self, seen_slugs=seen_slugs, agenda=self.agenda)
1144
        self.recurrence_rule = self.get_recurrence_rule()
1145 1158
        return super(Event, self).save(*args, **kwargs)
1146 1159

  
1147 1160
    @property
......
1165 1178
            return False
1166 1179
        if self.agenda.maximal_booking_delay and self.start_datetime > self.agenda.max_booking_datetime:
1167 1180
            return False
1168
        if self.recurrence_rule is not None:
1181
        if self.recurrence_days is not None:
1169 1182
            # bookable recurrences probably exist
1170 1183
            return True
1171 1184
        if self.agenda.minimal_booking_delay and self.start_datetime < self.agenda.min_booking_datetime:
......
1288 1301
        else:
1289 1302
            event = cls(**data)
1290 1303
            event.save()
1291
        if event.recurrence_rule and event.recurrence_end_date:
1304
        if event.recurrence_days and event.recurrence_end_date:
1292 1305
            event.refresh_from_db()
1293 1306
            event.recurrences.filter(start_datetime__gt=event.recurrence_end_date).delete()
1294 1307
            update_fields = {
......
1317 1330
        return {
1318 1331
            'start_datetime': make_naive(self.start_datetime).strftime('%Y-%m-%d %H:%M:%S'),
1319 1332
            'publication_date': self.publication_date.strftime('%Y-%m-%d') if self.publication_date else None,
1320
            'repeat': self.repeat,
1321
            'recurrence_rule': self.recurrence_rule,
1333
            'recurrence_days': self.recurrence_days,
1334
            'recurrence_week_interval': self.recurrence_week_interval,
1322 1335
            'recurrence_end_date': recurrence_end_date,
1323 1336
            'places': self.places,
1324 1337
            'waiting_list_places': self.waiting_list_places,
......
1391 1404

  
1392 1405
        if self.publication_date and self.publication_date > min_datetime.date():
1393 1406
            min_datetime = make_aware(datetime.datetime.combine(self.publication_date, datetime.time(0, 0)))
1394
        if self.recurrence_end_date:
1395
            self.recurrence_rule['until'] = datetime.datetime.combine(
1396
                self.recurrence_end_date, datetime.time(0, 0)
1397
            )
1398 1407

  
1399 1408
        # remove pytz info because dateutil doesn't support DST changes
1400 1409
        min_datetime = make_naive(min_datetime)
......
1416 1425
        return recurrences
1417 1426

  
1418 1427
    def get_recurrence_display(self):
1419
        repeat = str(self.get_repeat_display())
1420 1428
        time = date_format(localtime(self.start_datetime), 'TIME_FORMAT')
1421
        if self.repeat in ('weekly', '2-weeks'):
1422
            day = date_format(localtime(self.start_datetime), 'l')
1423
            return _('%(every_x_days)s on %(day)s at %(time)s') % {
1424
                'every_x_days': repeat,
1425
                'day': day,
1426
                'time': time,
1429

  
1430
        days_count = len(self.recurrence_days)
1431
        if days_count == 7:
1432
            repeat = _('Daily')
1433
        elif days_count > 1 and (self.recurrence_days[-1] - self.recurrence_days[0]) == days_count - 1:
1434
            # days are contiguous
1435
            repeat = _('From %(weekday)s to %(last_weekday)s') % {
1436
                'weekday': str(WEEKDAYS[self.recurrence_days[0]]),
1437
                'last_weekday': str(WEEKDAYS[self.recurrence_days[-1]]),
1427 1438
            }
1428 1439
        else:
1429
            return _('%(every_x_days)s at %(time)s') % {'every_x_days': repeat, 'time': time}
1430

  
1431
    def get_recurrence_rule(self):
1432
        rrule = {}
1433
        if self.repeat == 'daily':
1434
            rrule['freq'] = DAILY
1435
        elif self.repeat == 'weekly':
1436
            rrule['freq'] = WEEKLY
1437
            rrule['byweekday'] = [localtime(self.start_datetime).weekday()]
1438
        elif self.repeat == '2-weeks':
1439
            rrule['freq'] = WEEKLY
1440
            rrule['byweekday'] = [localtime(self.start_datetime).weekday()]
1441
            rrule['interval'] = 2
1442
        elif self.repeat == 'weekdays':
1443
            rrule['freq'] = WEEKLY
1444
            rrule['byweekday'] = [i for i in range(5)]
1445
        else:
1446
            return None
1447
        return rrule
1440
            repeat = _('On %(weekdays)s') % {
1441
                'weekdays': ', '.join([str(WEEKDAYS[i]) for i in self.recurrence_days])
1442
            }
1443

  
1444
        recurrence_display = _('%(On_day_x)s at %(time)s') % {'On_day_x': repeat, 'time': time}
1445

  
1446
        if self.recurrence_week_interval > 1:
1447
            if self.recurrence_week_interval == 2:
1448
                every_x_weeks = _('every two weeks')
1449
            elif self.recurrence_week_interval == 3:
1450
                every_x_weeks = _('every three weeks')
1451
            recurrence_display = _('%(Every_x_days)s, once %(every_x_weeks)s') % {
1452
                'Every_x_days': recurrence_display,
1453
                'every_x_weeks': every_x_weeks,
1454
            }
1455

  
1456
        if self.recurrence_end_date:
1457
            end_date = date_format(self.recurrence_end_date, 'DATE_FORMAT')
1458
            recurrence_display = _('%(Every_x_days)s, until %(date)s') % {
1459
                'Every_x_days': recurrence_display,
1460
                'date': end_date,
1461
            }
1462
        return recurrence_display
1463

  
1464
    @property
1465
    def recurrence_rule(self):
1466
        recurrence_rule = {
1467
            'freq': WEEKLY,
1468
            'byweekday': self.recurrence_days,
1469
            'interval': self.recurrence_week_interval,
1470
        }
1471
        if self.recurrence_end_date:
1472
            recurrence_rule['until'] = datetime.datetime.combine(
1473
                self.recurrence_end_date, datetime.time(0, 0)
1474
            )
1475
        return recurrence_rule
1448 1476

  
1449 1477
    def has_recurrences_booked(self, after=None):
1450 1478
        return Booking.objects.filter(
chrono/api/views.py
550 550
                cancelled=False,
551 551
                start_datetime__gte=localtime(now()),
552 552
            ).order_by()
553
            recurring_event_queryset = Event.objects.filter(recurrence_rule__isnull=False)
553
            recurring_event_queryset = Event.objects.filter(recurrence_days__isnull=False)
554 554
            agendas_queryset = agendas_queryset.filter(kind='events').prefetch_related(
555 555
                Prefetch(
556 556
                    'event_set',
chrono/manager/forms.py
50 50
)
51 51

  
52 52
from . import widgets
53
from .widgets import SplitDateTimeField
53
from .widgets import SplitDateTimeField, WeekdaysWidget
54 54

  
55 55

  
56 56
class AgendaAddForm(forms.ModelForm):
......
141 141

  
142 142

  
143 143
class NewEventForm(forms.ModelForm):
144
    frequency = forms.ChoiceField(
145
        label=_('Event frequency'),
146
        widget=forms.RadioSelect,
147
        choices=(
148
            ('unique', _('Unique')),
149
            ('recurring', _('Recurring')),
150
        ),
151
        initial='unique',
152
    )
153
    recurrence_days = forms.TypedMultipleChoiceField(
154
        choices=Event.WEEKDAY_CHOICES, coerce=int, required=False, widget=WeekdaysWidget
155
    )
156

  
144 157
    class Meta:
145 158
        model = Event
146 159
        fields = [
147 160
            'label',
148 161
            'start_datetime',
149
            'repeat',
162
            'frequency',
163
            'recurrence_days',
164
            'recurrence_week_interval',
165
            'recurrence_end_date',
150 166
            'duration',
151 167
            'places',
152 168
        ]
......
154 170
            'start_datetime': SplitDateTimeField,
155 171
        }
156 172

  
173
    def clean_recurrence_days(self):
174
        recurrence_days = self.cleaned_data['recurrence_days']
175
        if recurrence_days == []:
176
            return None
177
        return recurrence_days
157 178

  
158
class EventForm(forms.ModelForm):
159
    protected_fields = ('repeat', 'slug', 'start_datetime')
179

  
180
class EventForm(NewEventForm):
181
    protected_fields = (
182
        'slug',
183
        'start_datetime',
184
        'frequency',
185
        'recurrence_days',
186
        'recurrence_week_interval',
187
    )
160 188

  
161 189
    class Meta:
162 190
        model = Event
......
168 196
            'label',
169 197
            'slug',
170 198
            'start_datetime',
171
            'repeat',
199
            'frequency',
200
            'recurrence_days',
201
            'recurrence_week_interval',
172 202
            'recurrence_end_date',
173 203
            'duration',
174 204
            'publication_date',
......
184 214

  
185 215
    def __init__(self, *args, **kwargs):
186 216
        super().__init__(*args, **kwargs)
187
        if self.instance.recurrence_rule and self.instance.has_recurrences_booked():
217
        self.fields['frequency'].initial = 'recurring' if self.instance.recurrence_days else 'unique'
218
        if self.instance.recurrence_days and self.instance.has_recurrences_booked():
188 219
            for field in self.protected_fields:
189 220
                self.fields[field].disabled = True
190 221
                self.fields[field].help_text = _(
191 222
                    'This field cannot be modified because some recurrences have bookings attached to them.'
192 223
                )
193 224
        if self.instance.primary_event:
194
            for field in ('slug', 'repeat', 'recurrence_end_date', 'publication_date'):
225
            for field in (
226
                'slug',
227
                'recurrence_end_date',
228
                'publication_date',
229
                'frequency',
230
                'recurrence_days',
231
                'recurrence_week_interval',
232
            ):
195 233
                del self.fields[field]
196 234

  
197 235
    def clean(self):
......
200 238
            after=self.cleaned_data['recurrence_end_date']
201 239
        ):
202 240
            raise ValidationError(_('Bookings exist after this date.'))
203
        if self.cleaned_data.get('recurrence_end_date') and not self.cleaned_data.get('repeat'):
204
            raise ValidationError(_('Recurrence end date makes no sense without repetition.'))
241

  
242
        if self.cleaned_data.get('frequency') == 'unique':
243
            self.cleaned_data['recurrence_days'] = None
244
            self.cleaned_data['recurrence_end_date'] = None
205 245

  
206 246
    def save(self, *args, **kwargs):
207 247
        with transaction.atomic():
208 248
            if any(field for field in self.changed_data if field in self.protected_fields):
209 249
                self.instance.recurrences.all().delete()
210
            elif self.instance.recurrence_rule:
250
            elif self.instance.recurrence_days:
211 251
                update_fields = {
212 252
                    field: value
213 253
                    for field, value in self.cleaned_data.items()
214
                    if field not in self.protected_fields
254
                    if field != 'frequency' and field not in self.protected_fields
215 255
                }
216 256
                self.instance.recurrences.update(**update_fields)
217 257

  
chrono/manager/static/css/style.scss
411 411
		background-color: $color;
412 412
	}
413 413
}
414

  
415
form div.widget[id^=id_recurrence] {
416
      padding-left: 1em;
417
}
chrono/manager/templates/chrono/manager_agenda_event_fragment.html
20 20
    {% else %}
21 21
      {% if event.label %}{{ event.label }} / {% endif %}
22 22
    {% endif %}
23
    {% if not event.repeat %}
23
    {% if not event.recurrence_days %}
24 24
      {% if view_mode == 'day_view' %}{{ event.start_datetime|time }}{% else %}{{ event.start_datetime }}{% endif %}
25 25
    {% else %}
26 26
      {{ event.get_recurrence_display }}
chrono/manager/templates/chrono/manager_event_form.html
1 1
{% extends "chrono/manager_agenda_view.html" %}
2
{% load i18n %}
2
{% load i18n gadjo %}
3 3

  
4 4
{% block extrascripts %}
5 5
{{ block.super }}
......
29 29
<form method="post" enctype="multipart/form-data">
30 30
  {% csrf_token %}
31 31
  <input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}">
32
  {{ form.as_p }}
32
  {{ form|with_template }}
33 33
  <div class="buttons">
34 34
    <button class="submit-button">{% trans "Save" %}</button>
35 35
    <a class="cancel" href="{{ view.get_success_url }}">{% trans 'Cancel' %}</a>
36 36
  </div>
37

  
38
  <script>
39
    $(function () {
40
      recurrence_fields = $('[id^=id_recurrence]');
41
      $('input[type=radio][name=frequency]').change(function() {
42
        if(!this.checked)
43
          return;
44
        if(this.value == 'unique') {
45
          recurrence_fields.hide();
46
        } else {
47
          recurrence_fields.show();
48
        }
49
      }).change();
50
    });
51
  </script>
37 52
</form>
38 53
{% endblock %}
chrono/manager/templates/chrono/widgets/weekdays.html
1
{% spaceless %}
2
<span id="{{ widget.attrs.id }}" class="inputs-buttons-group{% if widget.attrs.class %} {{ widget.attrs.class }}{% endif %}">
3
	{% for group, options, index in widget.optgroups %}
4
	{% for option in options %}
5
	<label{% if option.attrs.id %} for="{{ option.attrs.id }}"{% endif %}>{{ option.label }}</label>
6
	{% include "django/forms/widgets/input.html" with widget=option %}
7
	{% endfor %}
8
	{% endfor %}
9
</span>
10
{% endspaceless %}
chrono/manager/views.py
1052 1052

  
1053 1053
    def get_queryset(self):
1054 1054
        if self.agenda.kind == 'events':
1055
            queryset = self.agenda.event_set.filter(recurrence_rule__isnull=True)
1055
            queryset = self.agenda.event_set.filter(recurrence_days__isnull=True)
1056 1056
        else:
1057 1057
            self.agenda.prefetch_desks_and_exceptions()
1058 1058
            if self.agenda.kind == 'meetings':
......
1850 1850
    pk_url_kwarg = 'event_pk'
1851 1851

  
1852 1852
    def dispatch(self, request, *args, **kwargs):
1853
        if self.get_object().recurrence_rule:
1853
        if self.get_object().recurrence_days:
1854 1854
            raise Http404('this view makes no sense for recurring events')
1855 1855
        return super().dispatch(request, *args, **kwargs)
1856 1856

  
......
1885 1885
        if (
1886 1886
            self.request.GET.get('next') == 'settings'
1887 1887
            or self.request.POST.get('next') == 'settings'
1888
            or self.object.recurrence_rule
1888
            or self.object.recurrence_days
1889 1889
        ):
1890 1890
            return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.agenda.id})
1891 1891
        return reverse('chrono-manager-event-view', kwargs={'pk': self.agenda.id, 'event_pk': self.object.id})
chrono/manager/widgets.py
16 16

  
17 17

  
18 18
from django.forms.fields import SplitDateTimeField
19
from django.forms.widgets import TimeInput, SplitDateTimeWidget
19
from django.forms.widgets import TimeInput, SplitDateTimeWidget, CheckboxSelectMultiple
20 20
from django.utils.safestring import mark_safe
21 21

  
22 22

  
......
59 59
        super(TimeWidget, self).__init__(**kwargs)
60 60
        self.attrs['step'] = '300'  # 5 minutes
61 61
        self.attrs['pattern'] = '[0-9]{2}:[0-9]{2}'
62

  
63

  
64
class WeekdaysWidget(CheckboxSelectMultiple):
65
    template_name = 'chrono/widgets/weekdays.html'
66

  
67
    def id_for_label(self, id_, index=None):
68
        """Workaround CheckboxSelectMultiple id_for_label, which would return empty string when
69
        index is None, leading to more complicated JS from our side."""
70
        if index is None:
71
            index = ''
72
        return super(CheckboxSelectMultiple, self).id_for_label(id_, index)
tests/manager/test_all.py
2580 2580
    Event.objects.create(
2581 2581
        label='xyz', start_datetime=localtime().replace(day=11, month=11, year=2020), places=10, agenda=agenda
2582 2582
    )
2583
    recurring_start_datetime = localtime().replace(day=4, month=11, year=2020)
2583 2584
    event = Event.objects.create(
2584 2585
        label='abc',
2585
        start_datetime=localtime().replace(day=4, month=11, year=2020),
2586
        start_datetime=recurring_start_datetime,
2586 2587
        places=10,
2587 2588
        agenda=agenda,
2588
        repeat='weekly',
2589
        recurrence_days=[recurring_start_datetime.weekday()],
2589 2590
    )
2590 2591

  
2591 2592
    with CaptureQueriesContext(connection) as ctx:
......
2608 2609
    # create another event with recurrence, the same day/time
2609 2610
    start_datetime = localtime().replace(day=4, month=11, year=2020)
2610 2611
    event = Event.objects.create(
2611
        label='def', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly'
2612
        label='def',
2613
        start_datetime=start_datetime,
2614
        places=10,
2615
        agenda=agenda,
2616
        recurrence_days=[start_datetime.weekday()],
2612 2617
    )
2613 2618
    resp = app.get('/manage/agendas/%s/2020/11/11/' % agenda.pk)
2614 2619
    # the event occurence in DB does not hide recurrence of the second recurrent event
......
2648 2653
    # add recurring event on every Wednesday
2649 2654
    start_datetime = localtime().replace(day=4, month=11, year=2020)
2650 2655
    event = Event.objects.create(
2651
        label='abc', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly'
2656
        label='abc',
2657
        start_datetime=start_datetime,
2658
        places=10,
2659
        agenda=agenda,
2660
        recurrence_days=[start_datetime.weekday()],
2652 2661
    )
2653 2662

  
2654 2663
    with CaptureQueriesContext(connection) as ctx:
......
2677 2686
    # create another event with recurrence, the same day/time
2678 2687
    start_datetime = localtime().replace(day=4, month=11, year=2020)
2679 2688
    event = Event.objects.create(
2680
        label='def', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly'
2689
        label='def',
2690
        start_datetime=start_datetime,
2691
        places=10,
2692
        agenda=agenda,
2693
        recurrence_days=[start_datetime.weekday()],
2681 2694
    )
2682 2695
    resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 12))
2683 2696
    # the event occurence in DB does not hide recurrence of the second recurrent event
......
2760 2773
        places=42,
2761 2774
    )
2762 2775
    # weekly recurring event, first recurrence is in the past but second is in range
2776
    start_datetime = now() - datetime.timedelta(days=3)
2763 2777
    event = Event.objects.create(
2764 2778
        label='event G',
2765
        start_datetime=now() - datetime.timedelta(days=3),
2779
        start_datetime=start_datetime,
2766 2780
        places=10,
2767 2781
        agenda=agenda,
2768
        repeat='weekly',
2782
        recurrence_days=[start_datetime.weekday()],
2769 2783
    )
2770 2784
    resp = app.get('/manage/agendas/%s/events/open/' % agenda.pk)
2771 2785
    assert 'event A' not in resp.text
tests/manager/test_event.py
83 83
        assert (
84 84
            resp.text.count('Enter a valid date')
85 85
            or resp.text.count('Enter a valid time') == 1
86
            or resp.text.count('This field is required.') == 1
86
            or resp.text.count('This field is required.') >= 1
87 87
        )
88 88

  
89 89

  
......
223 223

  
224 224
    app = login(app)
225 225
    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
226
    resp.form['repeat'] = 'weekly'
226
    resp.form['frequency'] = 'recurring'
227
    resp.form['recurrence_days'] = [localtime().weekday()]
227 228
    resp = resp.form.submit()
228 229

  
229 230
    # detail page doesn't exist
230 231
    resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id), status=404)
231 232

  
232 233
    resp = app.get('/manage/agendas/%s/settings' % agenda.id, status=200)
233
    assert 'Weekly on Tuesday at 1:10 p.m.' in resp.text
234
    assert 'On Tuesday at 1:10 p.m.' in resp.text
234 235
    # event is bookable regardless of minimal_booking_delay, since it has bookable recurrences
235 236
    assert len(resp.pyquery.find('.bookable')) == 1
236 237

  
......
250 251

  
251 252
    # changing recurrence attribute removes event recurrences
252 253
    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
253
    resp.form['repeat'] = ''
254
    resp.form['frequency'] = 'unique'
254 255
    resp = resp.form.submit().follow()
255 256
    assert not Event.objects.filter(primary_event=event).exists()
256 257

  
......
268 269
    event_recurrence = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=7))
269 270
    Booking.objects.create(event=event_recurrence)
270 271
    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
271
    assert 'disabled' in resp.form['repeat'].attrs
272
    assert 'disabled' in resp.form['frequency'].attrs
273
    assert all('disabled' in resp.form.get('recurrence_days', index=i).attrs for i in range(7))
274
    assert 'disabled' in resp.form['recurrence_week_interval'].attrs
272 275
    assert 'disabled' in resp.form['slug'].attrs
273 276
    assert 'disabled' in resp.form['start_datetime_0'].attrs
274 277
    assert 'disabled' in resp.form['start_datetime_1'].attrs
......
283 286
    assert 'Delete' not in resp.text
284 287

  
285 288
    resp = resp.click('Options')
286
    assert {'slug', 'repeat', 'recurrence_end_date', 'publication_date'}.isdisjoint(resp.form.fields)
289
    assert {
290
        'slug',
291
        'frequency',
292
        'recurrence_days',
293
        'recurence_weekly_interval',
294
        'recurrence_end_date',
295
        'publication_date',
296
    }.isdisjoint(resp.form.fields)
287 297

  
288 298

  
289 299
def test_edit_recurring_event_with_end_date(settings, app, admin_user, freezer):
290 300
    freezer.move_to('2021-01-12 12:10')
291 301
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
292
    event = Event.objects.create(start_datetime=now(), places=10, repeat='daily', agenda=agenda)
302
    event = Event.objects.create(
303
        start_datetime=now(), places=10, recurrence_days=list(range(7)), agenda=agenda
304
    )
293 305

  
294 306
    app = login(app)
295 307
    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
......
297 309
    resp = resp.form.submit()
298 310

  
299 311
    # recurrences are created automatically
300
    event = Event.objects.get(recurrence_rule__isnull=False)
312
    event = Event.objects.get(recurrence_days__isnull=False)
301 313
    assert Event.objects.filter(primary_event=event).count() == 5
302 314
    assert Event.objects.filter(primary_event=event, start_datetime=now()).exists()
303 315

  
......
339 351
    assert Event.objects.filter(primary_event=event).count() == 4
340 352
    assert 'Bookings exist after this date' in resp.text
341 353

  
342
    Booking.objects.all().delete()
343
    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
344
    resp.form['repeat'] = ''
345
    resp = resp.form.submit()
346
    assert 'Recurrence end date makes no sense without repetition.' in resp.text
347

  
348 354

  
349 355
def test_booked_places(app, admin_user):
350 356
    agenda = Agenda(label=u'Foo bar')
......
438 444
def test_delete_recurring_event(app, admin_user, freezer):
439 445
    agenda = Agenda.objects.create(label='Foo bar', kind='events')
440 446
    start_datetime = now() + datetime.timedelta(days=10)
441
    event = Event.objects.create(start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly')
447
    event = Event.objects.create(
448
        start_datetime=start_datetime, places=10, agenda=agenda, recurrence_days=[start_datetime.weekday()]
449
    )
442 450

  
443 451
    app = login(app)
444 452
    resp = app.get('/manage/agendas/%s/settings' % agenda.id, status=200)
tests/test_agendas.py
1852 1852
    event = Event.objects.create(
1853 1853
        agenda=agenda,
1854 1854
        start_datetime=now(),
1855
        repeat='weekly',
1855
        recurrence_days=[now().weekday()],
1856 1856
        label='Event',
1857 1857
        places=10,
1858 1858
        waiting_list_places=10,
......
1871 1871

  
1872 1872
    event_json = event.export_json()
1873 1873
    first_event_json = first_event.export_json()
1874
    different_fields = ['slug', 'repeat', 'recurrence_rule']
1874
    different_fields = ['slug', 'recurrence_days', 'recurrence_week_interval']
1875 1875
    assert all(first_event_json[k] == event_json[k] for k in event_json if k not in different_fields)
1876 1876

  
1877 1877
    second_event = recurrences[1]
......
1895 1895
    freezer.move_to('2020-10-24 12:00')
1896 1896
    settings.TIME_ZONE = 'Europe/Brussels'
1897 1897
    agenda = Agenda.objects.create(label='Agenda', kind='events')
1898
    event = Event.objects.create(agenda=agenda, start_datetime=now(), repeat='weekly', places=5)
1898
    event = Event.objects.create(
1899
        agenda=agenda, start_datetime=now(), recurrence_days=[now().weekday()], places=5
1900
    )
1899 1901
    event.refresh_from_db()
1900 1902
    dt = localtime()
1901 1903
    recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8))
......
1913 1915
    assert event_after_dst.slug == new_event_after_dst.slug
1914 1916

  
1915 1917

  
1916
def test_recurring_events_weekday_midnight(freezer):
1917
    freezer.move_to('2021-01-06 23:30')
1918
    weekday = localtime().weekday()
1919
    agenda = Agenda.objects.create(label='Agenda', kind='events')
1920
    event = Event.objects.create(agenda=agenda, start_datetime=now(), repeat='weekly', places=5)
1921

  
1922
    assert event.recurrence_rule['byweekday'][0] == weekday
1923

  
1924

  
1925
def test_recurring_events_repeat(freezer):
1918
def test_recurring_events_repetition(freezer):
1926 1919
    freezer.move_to('2021-01-06 12:00')  # Wednesday
1927 1920
    agenda = Agenda.objects.create(label='Agenda', kind='events')
1928 1921
    event = Event.objects.create(
1929 1922
        agenda=agenda,
1930 1923
        start_datetime=now(),
1931
        repeat='daily',
1924
        recurrence_days=list(range(7)),  # everyday
1932 1925
        places=5,
1933 1926
    )
1934 1927
    event.refresh_from_db()
......
1944 1937
    for i in range(len(recurrences) - 1):
1945 1938
        assert recurrences[i].start_datetime + datetime.timedelta(days=1) == recurrences[i + 1].start_datetime
1946 1939

  
1947
    event.repeat = 'weekdays'
1940
    event.recurrence_days = list(range(5))  # from Monday to Friday
1948 1941
    event.save()
1949 1942
    recurrences = event.get_recurrences(
1950 1943
        localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7)
......
1954 1947
    assert recurrences[1].start_datetime == start_datetime + datetime.timedelta(days=5)
1955 1948
    assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7)
1956 1949

  
1957
    event.repeat = '2-weeks'
1950
    event.recurrence_days = [localtime(event.start_datetime).weekday()]  # from Monday to Friday
1951
    event.recurrence_week_interval = 2
1958 1952
    event.save()
1959 1953
    recurrences = event.get_recurrences(
1960 1954
        localtime() + datetime.timedelta(days=3), localtime() + datetime.timedelta(days=45)
......
1967 1961
            recurrences[i].start_datetime + datetime.timedelta(days=14) == recurrences[i + 1].start_datetime
1968 1962
        )
1969 1963

  
1964
    event.recurrence_days = [3]  # Tuesday but start_datetime is a Wednesday
1965
    event.recurrence_week_interval = 1
1966
    event.save()
1967
    recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=10))
1968
    assert len(recurrences) == 2
1969
    # no recurrence exist on Wednesday
1970
    assert all(localtime(r.start_datetime).weekday() == 3 for r in recurrences)
1971

  
1970 1972

  
1971 1973
@pytest.mark.freeze_time('2021-01-06')
1972 1974
def test_recurring_events_with_end_date():
......
1974 1976
    event = Event.objects.create(
1975 1977
        agenda=agenda,
1976 1978
        start_datetime=now(),
1977
        repeat='daily',
1979
        recurrence_days=list(range(7)),
1978 1980
        places=5,
1979 1981
        recurrence_end_date=(now() + datetime.timedelta(days=5)).date(),
1980 1982
    )
......
1992 1994
def test_recurring_events_sort(freezer):
1993 1995
    freezer.move_to('2021-01-06 12:00')  # Wednesday
1994 1996
    agenda = Agenda.objects.create(label='Agenda', kind='events')
1995
    Event.objects.create(agenda=agenda, slug='a', start_datetime=now(), repeat='daily', places=5)
1996
    Event.objects.create(agenda=agenda, slug='b', start_datetime=now(), repeat='daily', duration=10, places=5)
1997
    Event.objects.create(agenda=agenda, slug='c', start_datetime=now(), repeat='daily', duration=5, places=5)
1998 1997
    Event.objects.create(
1999
        agenda=agenda, slug='d', start_datetime=now() + datetime.timedelta(hours=1), repeat='daily', places=5
1998
        agenda=agenda, slug='a', start_datetime=now(), recurrence_days=list(range(7)), places=5
1999
    )
2000
    Event.objects.create(
2001
        agenda=agenda, slug='b', start_datetime=now(), recurrence_days=list(range(7)), duration=10, places=5
2002
    )
2003
    Event.objects.create(
2004
        agenda=agenda, slug='c', start_datetime=now(), recurrence_days=list(range(7)), duration=5, places=5
2005
    )
2006
    Event.objects.create(
2007
        agenda=agenda,
2008
        slug='d',
2009
        start_datetime=now() + datetime.timedelta(hours=1),
2010
        recurrence_days=list(range(7)),
2011
        places=5,
2000 2012
    )
2001 2013

  
2002 2014
    events = agenda.get_open_events()[:8]
2003 2015
    assert [e.primary_event.slug for e in events] == ['c', 'b', 'a', 'd', 'c', 'b', 'a', 'd']
2016

  
2017

  
2018
def test_recurring_events_display(freezer):
2019
    freezer.move_to('2021-01-06 12:30')
2020
    agenda = Agenda.objects.create(label='Agenda', kind='events')
2021
    event = Event.objects.create(
2022
        agenda=agenda, start_datetime=now(), recurrence_days=list(range(7)), places=5
2023
    )
2024

  
2025
    assert event.get_recurrence_display() == 'Daily at 1:30 p.m.'
2026

  
2027
    event.recurrence_days = [1, 2, 3, 4]
2028
    event.save()
2029
    assert event.get_recurrence_display() == 'From Tuesday to Friday at 1:30 p.m.'
2030

  
2031
    event.recurrence_days = [4, 5, 6]
2032
    event.save()
2033
    assert event.get_recurrence_display() == 'From Friday to Sunday at 1:30 p.m.'
2034

  
2035
    event.recurrence_days = [1, 4, 6]
2036
    event.save()
2037
    assert event.get_recurrence_display() == 'On Tuesday, Friday, Sunday at 1:30 p.m.'
2038

  
2039
    event.recurrence_days = [0]
2040
    event.recurrence_week_interval = 2
2041
    event.save()
2042
    assert event.get_recurrence_display() == 'On Monday at 1:30 p.m., once every two weeks'
2043

  
2044
    event.recurrence_week_interval = 3
2045
    event.recurrence_end_date = now() + datetime.timedelta(days=7)
2046
    event.save()
2047
    assert (
2048
        event.get_recurrence_display()
2049
        == 'On Monday at 1:30 p.m., once every three weeks, until Jan. 13, 2021'
2050
    )
tests/test_api.py
299 299
        start_datetime=now(),
300 300
        places=10,
301 301
        agenda=event_agenda,
302
        repeat='daily',
302
        recurrence_days=list(range(7)),
303 303
    )
304 304
    assert len(event_agenda.get_open_events()) == 2
305 305
    resp = app.get('/api/agenda/', params={'with_open_events': '1'})
......
547 547
    event.delete()
548 548

  
549 549
    # recurrent event
550
    start_datetime = localtime().replace(hour=12, minute=0)
550 551
    event = Event.objects.create(
551 552
        slug='recurrent',
552
        start_datetime=localtime().replace(hour=12, minute=0),
553
        repeat='weekly',
553
        start_datetime=start_datetime,
554
        recurrence_days=[start_datetime.weekday()],
554 555
        places=2,
555 556
        agenda=agenda,
556 557
    )
......
1257 1258
    event.delete()
1258 1259

  
1259 1260
    # recurrent event
1261
    start_datetime = localtime().replace(hour=12, minute=0)
1260 1262
    event = Event.objects.create(
1261 1263
        slug='recurrent',
1262
        start_datetime=localtime().replace(hour=12, minute=0),
1263
        repeat='weekly',
1264
        start_datetime=start_datetime,
1265
        recurrence_days=[start_datetime.weekday()],
1264 1266
        places=2,
1265 1267
        agenda=agenda,
1266 1268
    )
......
6080 6082
        label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30
6081 6083
    )
6082 6084
    base_event = Event.objects.create(
6083
        slug='abc', label='Test', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda
6085
        slug='abc',
6086
        label='Test',
6087
        start_datetime=localtime(),
6088
        recurrence_days=[localtime().weekday()],
6089
        places=5,
6090
        agenda=agenda,
6084 6091
    )
6085 6092

  
6086 6093
    resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
......
6148 6155
        label='Foo bar', kind='events', minimal_booking_delay=0, maximal_booking_delay=30
6149 6156
    )
6150 6157
    event = Event.objects.create(
6151
        slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda
6158
        slug='abc',
6159
        start_datetime=localtime(),
6160
        recurrence_days=[localtime().weekday()],
6161
        places=5,
6162
        agenda=agenda,
6152 6163
    )
6153 6164
    event.refresh_from_db()
6154 6165

  
tests/test_import_export.py
199 199
    event = Event.objects.create(
200 200
        agenda=agenda,
201 201
        start_datetime=now(),
202
        repeat='daily',
202
        recurrence_days=list(range(7)),
203
        recurrence_week_interval=2,
203 204
        places=10,
204 205
        slug='test',
205 206
    )
......
220 221
    assert Event.objects.count() == 1
221 222
    event = Agenda.objects.get(label='Foo Bar').event_set.first()
222 223
    assert event.primary_event is None
223
    assert event.repeat == 'daily'
224
    assert event.recurrence_rule == {'freq': DAILY}
224
    assert event.recurrence_days == list(range(7))
225
    assert event.recurrence_week_interval == 2
225 226

  
226 227
    # importing event with end recurrence date creates recurrences
227 228
    event.recurrence_end_date = now() + datetime.timedelta(days=7)
229
    event.recurrence_week_interval = 1
228 230
    event.save()
229 231
    output = get_output_of_command('export_site')
230 232
    import_site(data={}, clean=True)
231
-