From e676b1e02945ec3de1e805587b314df50e52702f Mon Sep 17 00:00:00 2001
From: Valentin Deniaud <vdeniaud@entrouvert.com>
Date: Wed, 21 Apr 2021 16:21:31 +0200
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

diff --git a/chrono/agendas/migrations/0079_auto_20210421_1556.py b/chrono/agendas/migrations/0079_auto_20210421_1556.py
new file mode 100644
index 0000000..4efad57
--- /dev/null
+++ b/chrono/agendas/migrations/0079_auto_20210421_1556.py
@@ -0,0 +1,80 @@
+# Generated by Django 2.2.19 on 2021-04-21 13:56
+
+import django.contrib.postgres.fields
+from django.db import migrations, models
+from dateutil.rrule import DAILY, WEEKLY
+
+
+def migrate_recurrence_fields(apps, schema_editor):
+    Event = apps.get_model('agendas', 'Event')
+
+    for event in Event.objects.filter(recurrence_rule__isnull=False):
+        if event.recurrence_rule['freq'] == DAILY:
+            event.recurrence_days = list(range(7))
+        elif event.recurrence_rule['freq'] == WEEKLY:
+            event.recurrence_days = event.recurrence_rule['byweekday']
+        event.recurrence_week_interval = event.recurrence_rule.get('interval', 1)
+        event.save()
+
+
+def reverse_migrate_recurrence_fields(apps, schema_editor):
+    Event = apps.get_model('agendas', 'Event')
+
+    for event in Event.objects.filter(recurrence_days__isnull=False):
+        rrule = {}
+        if event.recurrence_days == list(range(7)):
+            event.repeat = 'daily'
+            rrule['freq'] = DAILY
+        else:
+            rrule['freq'] = WEEKLY
+            rrule['byweekday'] = event.recurrence_days
+            if event.recurrence_days == list(range(5)):
+                event.repeat = 'weekdays'
+            elif event.recurrence_week_interval == 2:
+                event.repeat = '2-weeks'
+                rrule['interval'] = 2
+            else:
+                event.repeat = 'weekly'
+        event.recurrence_rule = rrule
+        event.save()
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('agendas', '0078_absence_reasons'),
+    ]
+
+    operations = [
+        migrations.AddField(
+            model_name='event',
+            name='recurrence_days',
+            field=django.contrib.postgres.fields.ArrayField(
+                base_field=models.IntegerField(
+                    choices=[(0, 'Mo'), (1, 'Tu'), (2, 'We'), (3, 'Th'), (4, 'Fr'), (5, 'Sa'), (6, 'Su')]
+                ),
+                blank=True,
+                null=True,
+                size=None,
+                verbose_name='Recurrence days',
+            ),
+        ),
+        migrations.AddField(
+            model_name='event',
+            name='recurrence_week_interval',
+            field=models.IntegerField(
+                choices=[(1, 'Every week'), (2, 'Every two weeks'), (3, 'Every three weeks')],
+                default=1,
+                verbose_name='Repeat',
+            ),
+        ),
+        migrations.RunPython(migrate_recurrence_fields, reverse_migrate_recurrence_fields),
+        migrations.RemoveField(
+            model_name='event',
+            name='recurrence_rule',
+        ),
+        migrations.RemoveField(
+            model_name='event',
+            name='repeat',
+        ),
+    ]
diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 0834bb2..0a1a6ba 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -26,7 +26,7 @@ import uuid
 
 import requests
 import vobject
-from dateutil.rrule import rrule, rruleset, DAILY, WEEKLY
+from dateutil.rrule import rrule, rruleset, WEEKLY
 
 import django
 from django.conf import settings
@@ -576,7 +576,7 @@ class Agenda(models.Model):
             entries = self.prefetched_events
         else:
             # recurring events are never opened
-            entries = self.event_set.filter(recurrence_rule__isnull=True)
+            entries = self.event_set.filter(recurrence_days__isnull=True)
             # exclude canceled events except for event recurrences
             entries = entries.filter(Q(cancelled=False) | Q(primary_event__isnull=False))
             # we never want to allow booking for past events.
@@ -644,7 +644,7 @@ class Agenda(models.Model):
         if prefetched_queryset:
             recurring_events = self.prefetched_recurring_events
         else:
-            recurring_events = self.event_set.filter(recurrence_rule__isnull=False)
+            recurring_events = self.event_set.filter(recurrence_days__isnull=False)
         for event in recurring_events:
             events.extend(
                 event.get_recurrences(
@@ -1076,17 +1076,31 @@ class MeetingType(models.Model):
 
 
 class Event(models.Model):
-    REPEAT_CHOICES = [
-        ('daily', _('Daily')),
-        ('weekly', _('Weekly')),
-        ('2-weeks', _('Once every two weeks')),
-        ('weekdays', _('Every weekdays (Monday to Friday)')),
+    WEEKDAY_CHOICES = [
+        (0, _('Mo')),
+        (1, _('Tu')),
+        (2, _('We')),
+        (3, _('Th')),
+        (4, _('Fr')),
+        (5, _('Sa')),
+        (6, _('Su')),
+    ]
+
+    INTERVAL_CHOICES = [
+        (1, 'Every week'),
+        (2, 'Every two weeks'),
+        (3, 'Every three weeks'),
     ]
 
     agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE)
     start_datetime = models.DateTimeField(_('Date/time'))
-    repeat = models.CharField(_('Repeat'), max_length=16, blank=True, choices=REPEAT_CHOICES)
-    recurrence_rule = JSONField(_('Recurrence rule'), null=True)
+    recurrence_days = ArrayField(
+        models.IntegerField(choices=WEEKDAY_CHOICES),
+        verbose_name=_('Recurrence days'),
+        blank=True,
+        null=True,
+    )
+    recurrence_week_interval = models.IntegerField(_('Repeat'), choices=INTERVAL_CHOICES, default=1)
     recurrence_end_date = models.DateField(_('Recurrence end date'), null=True, blank=True)
     primary_event = models.ForeignKey('self', null=True, on_delete=models.CASCADE, related_name='recurrences')
     duration = models.PositiveIntegerField(_('Duration (in minutes)'), default=None, null=True, blank=True)
@@ -1141,7 +1155,6 @@ class Event(models.Model):
         self.check_full()
         if not self.slug:
             self.slug = generate_slug(self, seen_slugs=seen_slugs, agenda=self.agenda)
-        self.recurrence_rule = self.get_recurrence_rule()
         return super(Event, self).save(*args, **kwargs)
 
     @property
@@ -1165,7 +1178,7 @@ class Event(models.Model):
             return False
         if self.agenda.maximal_booking_delay and self.start_datetime > self.agenda.max_booking_datetime:
             return False
-        if self.recurrence_rule is not None:
+        if self.recurrence_days is not None:
             # bookable recurrences probably exist
             return True
         if self.agenda.minimal_booking_delay and self.start_datetime < self.agenda.min_booking_datetime:
@@ -1288,7 +1301,7 @@ class Event(models.Model):
         else:
             event = cls(**data)
             event.save()
-        if event.recurrence_rule and event.recurrence_end_date:
+        if event.recurrence_days and event.recurrence_end_date:
             event.refresh_from_db()
             event.recurrences.filter(start_datetime__gt=event.recurrence_end_date).delete()
             update_fields = {
@@ -1317,8 +1330,8 @@ class Event(models.Model):
         return {
             'start_datetime': make_naive(self.start_datetime).strftime('%Y-%m-%d %H:%M:%S'),
             'publication_date': self.publication_date.strftime('%Y-%m-%d') if self.publication_date else None,
-            'repeat': self.repeat,
-            'recurrence_rule': self.recurrence_rule,
+            'recurrence_days': self.recurrence_days,
+            'recurrence_week_interval': self.recurrence_week_interval,
             'recurrence_end_date': recurrence_end_date,
             'places': self.places,
             'waiting_list_places': self.waiting_list_places,
@@ -1391,10 +1404,6 @@ class Event(models.Model):
 
         if self.publication_date and self.publication_date > min_datetime.date():
             min_datetime = make_aware(datetime.datetime.combine(self.publication_date, datetime.time(0, 0)))
-        if self.recurrence_end_date:
-            self.recurrence_rule['until'] = datetime.datetime.combine(
-                self.recurrence_end_date, datetime.time(0, 0)
-            )
 
         # remove pytz info because dateutil doesn't support DST changes
         min_datetime = make_naive(min_datetime)
@@ -1416,35 +1425,54 @@ class Event(models.Model):
         return recurrences
 
     def get_recurrence_display(self):
-        repeat = str(self.get_repeat_display())
         time = date_format(localtime(self.start_datetime), 'TIME_FORMAT')
-        if self.repeat in ('weekly', '2-weeks'):
-            day = date_format(localtime(self.start_datetime), 'l')
-            return _('%(every_x_days)s on %(day)s at %(time)s') % {
-                'every_x_days': repeat,
-                'day': day,
-                'time': time,
+
+        days_count = len(self.recurrence_days)
+        if days_count == 7:
+            repeat = _('Daily')
+        elif days_count > 1 and (self.recurrence_days[-1] - self.recurrence_days[0]) == days_count - 1:
+            # days are contiguous
+            repeat = _('From %(weekday)s to %(last_weekday)s') % {
+                'weekday': str(WEEKDAYS[self.recurrence_days[0]]),
+                'last_weekday': str(WEEKDAYS[self.recurrence_days[-1]]),
             }
         else:
-            return _('%(every_x_days)s at %(time)s') % {'every_x_days': repeat, 'time': time}
-
-    def get_recurrence_rule(self):
-        rrule = {}
-        if self.repeat == 'daily':
-            rrule['freq'] = DAILY
-        elif self.repeat == 'weekly':
-            rrule['freq'] = WEEKLY
-            rrule['byweekday'] = [localtime(self.start_datetime).weekday()]
-        elif self.repeat == '2-weeks':
-            rrule['freq'] = WEEKLY
-            rrule['byweekday'] = [localtime(self.start_datetime).weekday()]
-            rrule['interval'] = 2
-        elif self.repeat == 'weekdays':
-            rrule['freq'] = WEEKLY
-            rrule['byweekday'] = [i for i in range(5)]
-        else:
-            return None
-        return rrule
+            repeat = _('On %(weekdays)s') % {
+                'weekdays': ', '.join([str(WEEKDAYS[i]) for i in self.recurrence_days])
+            }
+
+        recurrence_display = _('%(On_day_x)s at %(time)s') % {'On_day_x': repeat, 'time': time}
+
+        if self.recurrence_week_interval > 1:
+            if self.recurrence_week_interval == 2:
+                every_x_weeks = _('every two weeks')
+            elif self.recurrence_week_interval == 3:
+                every_x_weeks = _('every three weeks')
+            recurrence_display = _('%(Every_x_days)s, once %(every_x_weeks)s') % {
+                'Every_x_days': recurrence_display,
+                'every_x_weeks': every_x_weeks,
+            }
+
+        if self.recurrence_end_date:
+            end_date = date_format(self.recurrence_end_date, 'DATE_FORMAT')
+            recurrence_display = _('%(Every_x_days)s, until %(date)s') % {
+                'Every_x_days': recurrence_display,
+                'date': end_date,
+            }
+        return recurrence_display
+
+    @property
+    def recurrence_rule(self):
+        recurrence_rule = {
+            'freq': WEEKLY,
+            'byweekday': self.recurrence_days,
+            'interval': self.recurrence_week_interval,
+        }
+        if self.recurrence_end_date:
+            recurrence_rule['until'] = datetime.datetime.combine(
+                self.recurrence_end_date, datetime.time(0, 0)
+            )
+        return recurrence_rule
 
     def has_recurrences_booked(self, after=None):
         return Booking.objects.filter(
diff --git a/chrono/api/views.py b/chrono/api/views.py
index e11bca7..06e9baa 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -550,7 +550,7 @@ class Agendas(APIView):
                 cancelled=False,
                 start_datetime__gte=localtime(now()),
             ).order_by()
-            recurring_event_queryset = Event.objects.filter(recurrence_rule__isnull=False)
+            recurring_event_queryset = Event.objects.filter(recurrence_days__isnull=False)
             agendas_queryset = agendas_queryset.filter(kind='events').prefetch_related(
                 Prefetch(
                     'event_set',
diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py
index 388f456..220a48d 100644
--- a/chrono/manager/forms.py
+++ b/chrono/manager/forms.py
@@ -50,7 +50,7 @@ from chrono.agendas.models import (
 )
 
 from . import widgets
-from .widgets import SplitDateTimeField
+from .widgets import SplitDateTimeField, WeekdaysWidget
 
 
 class AgendaAddForm(forms.ModelForm):
@@ -141,12 +141,28 @@ class UnavailabilityCalendarEditForm(UnavailabilityCalendarAddForm):
 
 
 class NewEventForm(forms.ModelForm):
+    frequency = forms.ChoiceField(
+        label=_('Event frequency'),
+        widget=forms.RadioSelect,
+        choices=(
+            ('unique', _('Unique')),
+            ('recurring', _('Recurring')),
+        ),
+        initial='unique',
+    )
+    recurrence_days = forms.TypedMultipleChoiceField(
+        choices=Event.WEEKDAY_CHOICES, coerce=int, required=False, widget=WeekdaysWidget
+    )
+
     class Meta:
         model = Event
         fields = [
             'label',
             'start_datetime',
-            'repeat',
+            'frequency',
+            'recurrence_days',
+            'recurrence_week_interval',
+            'recurrence_end_date',
             'duration',
             'places',
         ]
@@ -154,9 +170,21 @@ class NewEventForm(forms.ModelForm):
             'start_datetime': SplitDateTimeField,
         }
 
+    def clean_recurrence_days(self):
+        recurrence_days = self.cleaned_data['recurrence_days']
+        if recurrence_days == []:
+            return None
+        return recurrence_days
 
-class EventForm(forms.ModelForm):
-    protected_fields = ('repeat', 'slug', 'start_datetime')
+
+class EventForm(NewEventForm):
+    protected_fields = (
+        'slug',
+        'start_datetime',
+        'frequency',
+        'recurrence_days',
+        'recurrence_week_interval',
+    )
 
     class Meta:
         model = Event
@@ -168,7 +196,9 @@ class EventForm(forms.ModelForm):
             'label',
             'slug',
             'start_datetime',
-            'repeat',
+            'frequency',
+            'recurrence_days',
+            'recurrence_week_interval',
             'recurrence_end_date',
             'duration',
             'publication_date',
@@ -184,14 +214,22 @@ class EventForm(forms.ModelForm):
 
     def __init__(self, *args, **kwargs):
         super().__init__(*args, **kwargs)
-        if self.instance.recurrence_rule and self.instance.has_recurrences_booked():
+        self.fields['frequency'].initial = 'recurring' if self.instance.recurrence_days else 'unique'
+        if self.instance.recurrence_days and self.instance.has_recurrences_booked():
             for field in self.protected_fields:
                 self.fields[field].disabled = True
                 self.fields[field].help_text = _(
                     'This field cannot be modified because some recurrences have bookings attached to them.'
                 )
         if self.instance.primary_event:
-            for field in ('slug', 'repeat', 'recurrence_end_date', 'publication_date'):
+            for field in (
+                'slug',
+                'recurrence_end_date',
+                'publication_date',
+                'frequency',
+                'recurrence_days',
+                'recurrence_week_interval',
+            ):
                 del self.fields[field]
 
     def clean(self):
@@ -200,18 +238,20 @@ class EventForm(forms.ModelForm):
             after=self.cleaned_data['recurrence_end_date']
         ):
             raise ValidationError(_('Bookings exist after this date.'))
-        if self.cleaned_data.get('recurrence_end_date') and not self.cleaned_data.get('repeat'):
-            raise ValidationError(_('Recurrence end date makes no sense without repetition.'))
+
+        if self.cleaned_data.get('frequency') == 'unique':
+            self.cleaned_data['recurrence_days'] = None
+            self.cleaned_data['recurrence_end_date'] = None
 
     def save(self, *args, **kwargs):
         with transaction.atomic():
             if any(field for field in self.changed_data if field in self.protected_fields):
                 self.instance.recurrences.all().delete()
-            elif self.instance.recurrence_rule:
+            elif self.instance.recurrence_days:
                 update_fields = {
                     field: value
                     for field, value in self.cleaned_data.items()
-                    if field not in self.protected_fields
+                    if field != 'frequency' and field not in self.protected_fields
                 }
                 self.instance.recurrences.update(**update_fields)
 
diff --git a/chrono/manager/static/css/style.scss b/chrono/manager/static/css/style.scss
index ba8c5b8..03faea1 100644
--- a/chrono/manager/static/css/style.scss
+++ b/chrono/manager/static/css/style.scss
@@ -411,3 +411,7 @@ $booking-colors: (
 		background-color: $color;
 	}
 }
+
+form div.widget[id^=id_recurrence] {
+      padding-left: 1em;
+}
diff --git a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html
index 4bd12c2..aa0243a 100644
--- a/chrono/manager/templates/chrono/manager_agenda_event_fragment.html
+++ b/chrono/manager/templates/chrono/manager_agenda_event_fragment.html
@@ -20,7 +20,7 @@
     {% else %}
       {% if event.label %}{{ event.label }} / {% endif %}
     {% endif %}
-    {% if not event.repeat %}
+    {% if not event.recurrence_days %}
       {% if view_mode == 'day_view' %}{{ event.start_datetime|time }}{% else %}{{ event.start_datetime }}{% endif %}
     {% else %}
       {{ event.get_recurrence_display }}
diff --git a/chrono/manager/templates/chrono/manager_event_form.html b/chrono/manager/templates/chrono/manager_event_form.html
index 1d74945..7de1b11 100644
--- a/chrono/manager/templates/chrono/manager_event_form.html
+++ b/chrono/manager/templates/chrono/manager_event_form.html
@@ -1,5 +1,5 @@
 {% extends "chrono/manager_agenda_view.html" %}
-{% load i18n %}
+{% load i18n gadjo %}
 
 {% block extrascripts %}
 {{ block.super }}
@@ -29,10 +29,25 @@
 <form method="post" enctype="multipart/form-data">
   {% csrf_token %}
   <input type="hidden" name="next" value="{% firstof request.POST.next request.GET.next %}">
-  {{ form.as_p }}
+  {{ form|with_template }}
   <div class="buttons">
     <button class="submit-button">{% trans "Save" %}</button>
     <a class="cancel" href="{{ view.get_success_url }}">{% trans 'Cancel' %}</a>
   </div>
+
+  <script>
+    $(function () {
+      recurrence_fields = $('[id^=id_recurrence]');
+      $('input[type=radio][name=frequency]').change(function() {
+        if(!this.checked)
+          return;
+        if(this.value == 'unique') {
+          recurrence_fields.hide();
+        } else {
+          recurrence_fields.show();
+        }
+      }).change();
+    });
+  </script>
 </form>
 {% endblock %}
diff --git a/chrono/manager/templates/chrono/widgets/weekdays.html b/chrono/manager/templates/chrono/widgets/weekdays.html
new file mode 100644
index 0000000..8f5effc
--- /dev/null
+++ b/chrono/manager/templates/chrono/widgets/weekdays.html
@@ -0,0 +1,10 @@
+{% spaceless %}
+<span id="{{ widget.attrs.id }}" class="inputs-buttons-group{% if widget.attrs.class %} {{ widget.attrs.class }}{% endif %}">
+	{% for group, options, index in widget.optgroups %}
+	{% for option in options %}
+	<label{% if option.attrs.id %} for="{{ option.attrs.id }}"{% endif %}>{{ option.label }}</label>
+	{% include "django/forms/widgets/input.html" with widget=option %}
+	{% endfor %}
+	{% endfor %}
+</span>
+{% endspaceless %}
diff --git a/chrono/manager/views.py b/chrono/manager/views.py
index 0275b09..a2bdb19 100644
--- a/chrono/manager/views.py
+++ b/chrono/manager/views.py
@@ -1052,7 +1052,7 @@ class AgendaDateView(DateMixin, ViewableAgendaMixin):
 
     def get_queryset(self):
         if self.agenda.kind == 'events':
-            queryset = self.agenda.event_set.filter(recurrence_rule__isnull=True)
+            queryset = self.agenda.event_set.filter(recurrence_days__isnull=True)
         else:
             self.agenda.prefetch_desks_and_exceptions()
             if self.agenda.kind == 'meetings':
@@ -1850,7 +1850,7 @@ class EventDetailView(ViewableAgendaMixin, DetailView):
     pk_url_kwarg = 'event_pk'
 
     def dispatch(self, request, *args, **kwargs):
-        if self.get_object().recurrence_rule:
+        if self.get_object().recurrence_days:
             raise Http404('this view makes no sense for recurring events')
         return super().dispatch(request, *args, **kwargs)
 
@@ -1885,7 +1885,7 @@ class EventEditView(ManagedAgendaMixin, UpdateView):
         if (
             self.request.GET.get('next') == 'settings'
             or self.request.POST.get('next') == 'settings'
-            or self.object.recurrence_rule
+            or self.object.recurrence_days
         ):
             return reverse('chrono-manager-agenda-settings', kwargs={'pk': self.agenda.id})
         return reverse('chrono-manager-event-view', kwargs={'pk': self.agenda.id, 'event_pk': self.object.id})
diff --git a/chrono/manager/widgets.py b/chrono/manager/widgets.py
index 326cdbf..c5f859a 100644
--- a/chrono/manager/widgets.py
+++ b/chrono/manager/widgets.py
@@ -16,7 +16,7 @@
 
 
 from django.forms.fields import SplitDateTimeField
-from django.forms.widgets import TimeInput, SplitDateTimeWidget
+from django.forms.widgets import TimeInput, SplitDateTimeWidget, CheckboxSelectMultiple
 from django.utils.safestring import mark_safe
 
 
@@ -59,3 +59,14 @@ class TimeWidget(TimeInput):
         super(TimeWidget, self).__init__(**kwargs)
         self.attrs['step'] = '300'  # 5 minutes
         self.attrs['pattern'] = '[0-9]{2}:[0-9]{2}'
+
+
+class WeekdaysWidget(CheckboxSelectMultiple):
+    template_name = 'chrono/widgets/weekdays.html'
+
+    def id_for_label(self, id_, index=None):
+        """Workaround CheckboxSelectMultiple id_for_label, which would return empty string when
+        index is None, leading to more complicated JS from our side."""
+        if index is None:
+            index = ''
+        return super(CheckboxSelectMultiple, self).id_for_label(id_, index)
diff --git a/tests/manager/test_all.py b/tests/manager/test_all.py
index 189a87b..1df9e8e 100644
--- a/tests/manager/test_all.py
+++ b/tests/manager/test_all.py
@@ -2580,12 +2580,13 @@ def test_agenda_events_day_view(app, admin_user):
     Event.objects.create(
         label='xyz', start_datetime=localtime().replace(day=11, month=11, year=2020), places=10, agenda=agenda
     )
+    recurring_start_datetime = localtime().replace(day=4, month=11, year=2020)
     event = Event.objects.create(
         label='abc',
-        start_datetime=localtime().replace(day=4, month=11, year=2020),
+        start_datetime=recurring_start_datetime,
         places=10,
         agenda=agenda,
-        repeat='weekly',
+        recurrence_days=[recurring_start_datetime.weekday()],
     )
 
     with CaptureQueriesContext(connection) as ctx:
@@ -2608,7 +2609,11 @@ def test_agenda_events_day_view(app, admin_user):
     # create another event with recurrence, the same day/time
     start_datetime = localtime().replace(day=4, month=11, year=2020)
     event = Event.objects.create(
-        label='def', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly'
+        label='def',
+        start_datetime=start_datetime,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[start_datetime.weekday()],
     )
     resp = app.get('/manage/agendas/%s/2020/11/11/' % agenda.pk)
     # the event occurence in DB does not hide recurrence of the second recurrent event
@@ -2648,7 +2653,11 @@ def test_agenda_events_month_view(app, admin_user):
     # add recurring event on every Wednesday
     start_datetime = localtime().replace(day=4, month=11, year=2020)
     event = Event.objects.create(
-        label='abc', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly'
+        label='abc',
+        start_datetime=start_datetime,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[start_datetime.weekday()],
     )
 
     with CaptureQueriesContext(connection) as ctx:
@@ -2677,7 +2686,11 @@ def test_agenda_events_month_view(app, admin_user):
     # create another event with recurrence, the same day/time
     start_datetime = localtime().replace(day=4, month=11, year=2020)
     event = Event.objects.create(
-        label='def', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly'
+        label='def',
+        start_datetime=start_datetime,
+        places=10,
+        agenda=agenda,
+        recurrence_days=[start_datetime.weekday()],
     )
     resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 12))
     # the event occurence in DB does not hide recurrence of the second recurrent event
@@ -2760,12 +2773,13 @@ def test_agenda_open_events_view(app, admin_user, manager_user):
         places=42,
     )
     # weekly recurring event, first recurrence is in the past but second is in range
+    start_datetime = now() - datetime.timedelta(days=3)
     event = Event.objects.create(
         label='event G',
-        start_datetime=now() - datetime.timedelta(days=3),
+        start_datetime=start_datetime,
         places=10,
         agenda=agenda,
-        repeat='weekly',
+        recurrence_days=[start_datetime.weekday()],
     )
     resp = app.get('/manage/agendas/%s/events/open/' % agenda.pk)
     assert 'event A' not in resp.text
diff --git a/tests/manager/test_event.py b/tests/manager/test_event.py
index 4ff1b45..5ee0f83 100644
--- a/tests/manager/test_event.py
+++ b/tests/manager/test_event.py
@@ -83,7 +83,7 @@ def test_add_event(app, admin_user):
         assert (
             resp.text.count('Enter a valid date')
             or resp.text.count('Enter a valid time') == 1
-            or resp.text.count('This field is required.') == 1
+            or resp.text.count('This field is required.') >= 1
         )
 
 
@@ -223,14 +223,15 @@ def test_edit_recurring_event(settings, app, admin_user, freezer):
 
     app = login(app)
     resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
-    resp.form['repeat'] = 'weekly'
+    resp.form['frequency'] = 'recurring'
+    resp.form['recurrence_days'] = [localtime().weekday()]
     resp = resp.form.submit()
 
     # detail page doesn't exist
     resp = app.get('/manage/agendas/%s/events/%s/' % (agenda.id, event.id), status=404)
 
     resp = app.get('/manage/agendas/%s/settings' % agenda.id, status=200)
-    assert 'Weekly on Tuesday at 1:10 p.m.' in resp.text
+    assert 'On Tuesday at 1:10 p.m.' in resp.text
     # event is bookable regardless of minimal_booking_delay, since it has bookable recurrences
     assert len(resp.pyquery.find('.bookable')) == 1
 
@@ -250,7 +251,7 @@ def test_edit_recurring_event(settings, app, admin_user, freezer):
 
     # changing recurrence attribute removes event recurrences
     resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
-    resp.form['repeat'] = ''
+    resp.form['frequency'] = 'unique'
     resp = resp.form.submit().follow()
     assert not Event.objects.filter(primary_event=event).exists()
 
@@ -268,7 +269,9 @@ def test_edit_recurring_event(settings, app, admin_user, freezer):
     event_recurrence = event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=7))
     Booking.objects.create(event=event_recurrence)
     resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
-    assert 'disabled' in resp.form['repeat'].attrs
+    assert 'disabled' in resp.form['frequency'].attrs
+    assert all('disabled' in resp.form.get('recurrence_days', index=i).attrs for i in range(7))
+    assert 'disabled' in resp.form['recurrence_week_interval'].attrs
     assert 'disabled' in resp.form['slug'].attrs
     assert 'disabled' in resp.form['start_datetime_0'].attrs
     assert 'disabled' in resp.form['start_datetime_1'].attrs
@@ -283,13 +286,22 @@ def test_edit_recurring_event(settings, app, admin_user, freezer):
     assert 'Delete' not in resp.text
 
     resp = resp.click('Options')
-    assert {'slug', 'repeat', 'recurrence_end_date', 'publication_date'}.isdisjoint(resp.form.fields)
+    assert {
+        'slug',
+        'frequency',
+        'recurrence_days',
+        'recurence_weekly_interval',
+        'recurrence_end_date',
+        'publication_date',
+    }.isdisjoint(resp.form.fields)
 
 
 def test_edit_recurring_event_with_end_date(settings, app, admin_user, freezer):
     freezer.move_to('2021-01-12 12:10')
     agenda = Agenda.objects.create(label='Foo bar', kind='events')
-    event = Event.objects.create(start_datetime=now(), places=10, repeat='daily', agenda=agenda)
+    event = Event.objects.create(
+        start_datetime=now(), places=10, recurrence_days=list(range(7)), agenda=agenda
+    )
 
     app = login(app)
     resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
@@ -297,7 +309,7 @@ def test_edit_recurring_event_with_end_date(settings, app, admin_user, freezer):
     resp = resp.form.submit()
 
     # recurrences are created automatically
-    event = Event.objects.get(recurrence_rule__isnull=False)
+    event = Event.objects.get(recurrence_days__isnull=False)
     assert Event.objects.filter(primary_event=event).count() == 5
     assert Event.objects.filter(primary_event=event, start_datetime=now()).exists()
 
@@ -339,12 +351,6 @@ def test_edit_recurring_event_with_end_date(settings, app, admin_user, freezer):
     assert Event.objects.filter(primary_event=event).count() == 4
     assert 'Bookings exist after this date' in resp.text
 
-    Booking.objects.all().delete()
-    resp = app.get('/manage/agendas/%s/events/%s/edit' % (agenda.id, event.id))
-    resp.form['repeat'] = ''
-    resp = resp.form.submit()
-    assert 'Recurrence end date makes no sense without repetition.' in resp.text
-
 
 def test_booked_places(app, admin_user):
     agenda = Agenda(label=u'Foo bar')
@@ -438,7 +444,9 @@ def test_delete_busy_event(app, admin_user):
 def test_delete_recurring_event(app, admin_user, freezer):
     agenda = Agenda.objects.create(label='Foo bar', kind='events')
     start_datetime = now() + datetime.timedelta(days=10)
-    event = Event.objects.create(start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly')
+    event = Event.objects.create(
+        start_datetime=start_datetime, places=10, agenda=agenda, recurrence_days=[start_datetime.weekday()]
+    )
 
     app = login(app)
     resp = app.get('/manage/agendas/%s/settings' % agenda.id, status=200)
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index 667a84f..9011b7f 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -1852,7 +1852,7 @@ def test_recurring_events(freezer):
     event = Event.objects.create(
         agenda=agenda,
         start_datetime=now(),
-        repeat='weekly',
+        recurrence_days=[now().weekday()],
         label='Event',
         places=10,
         waiting_list_places=10,
@@ -1871,7 +1871,7 @@ def test_recurring_events(freezer):
 
     event_json = event.export_json()
     first_event_json = first_event.export_json()
-    different_fields = ['slug', 'repeat', 'recurrence_rule']
+    different_fields = ['slug', 'recurrence_days', 'recurrence_week_interval']
     assert all(first_event_json[k] == event_json[k] for k in event_json if k not in different_fields)
 
     second_event = recurrences[1]
@@ -1895,7 +1895,9 @@ def test_recurring_events_dst(freezer, settings):
     freezer.move_to('2020-10-24 12:00')
     settings.TIME_ZONE = 'Europe/Brussels'
     agenda = Agenda.objects.create(label='Agenda', kind='events')
-    event = Event.objects.create(agenda=agenda, start_datetime=now(), repeat='weekly', places=5)
+    event = Event.objects.create(
+        agenda=agenda, start_datetime=now(), recurrence_days=[now().weekday()], places=5
+    )
     event.refresh_from_db()
     dt = localtime()
     recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8))
@@ -1913,22 +1915,13 @@ def test_recurring_events_dst(freezer, settings):
     assert event_after_dst.slug == new_event_after_dst.slug
 
 
-def test_recurring_events_weekday_midnight(freezer):
-    freezer.move_to('2021-01-06 23:30')
-    weekday = localtime().weekday()
-    agenda = Agenda.objects.create(label='Agenda', kind='events')
-    event = Event.objects.create(agenda=agenda, start_datetime=now(), repeat='weekly', places=5)
-
-    assert event.recurrence_rule['byweekday'][0] == weekday
-
-
-def test_recurring_events_repeat(freezer):
+def test_recurring_events_repetition(freezer):
     freezer.move_to('2021-01-06 12:00')  # Wednesday
     agenda = Agenda.objects.create(label='Agenda', kind='events')
     event = Event.objects.create(
         agenda=agenda,
         start_datetime=now(),
-        repeat='daily',
+        recurrence_days=list(range(7)),  # everyday
         places=5,
     )
     event.refresh_from_db()
@@ -1944,7 +1937,7 @@ def test_recurring_events_repeat(freezer):
     for i in range(len(recurrences) - 1):
         assert recurrences[i].start_datetime + datetime.timedelta(days=1) == recurrences[i + 1].start_datetime
 
-    event.repeat = 'weekdays'
+    event.recurrence_days = list(range(5))  # from Monday to Friday
     event.save()
     recurrences = event.get_recurrences(
         localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7)
@@ -1954,7 +1947,8 @@ def test_recurring_events_repeat(freezer):
     assert recurrences[1].start_datetime == start_datetime + datetime.timedelta(days=5)
     assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7)
 
-    event.repeat = '2-weeks'
+    event.recurrence_days = [localtime(event.start_datetime).weekday()]  # from Monday to Friday
+    event.recurrence_week_interval = 2
     event.save()
     recurrences = event.get_recurrences(
         localtime() + datetime.timedelta(days=3), localtime() + datetime.timedelta(days=45)
@@ -1967,6 +1961,14 @@ def test_recurring_events_repeat(freezer):
             recurrences[i].start_datetime + datetime.timedelta(days=14) == recurrences[i + 1].start_datetime
         )
 
+    event.recurrence_days = [3]  # Tuesday but start_datetime is a Wednesday
+    event.recurrence_week_interval = 1
+    event.save()
+    recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=10))
+    assert len(recurrences) == 2
+    # no recurrence exist on Wednesday
+    assert all(localtime(r.start_datetime).weekday() == 3 for r in recurrences)
+
 
 @pytest.mark.freeze_time('2021-01-06')
 def test_recurring_events_with_end_date():
@@ -1974,7 +1976,7 @@ def test_recurring_events_with_end_date():
     event = Event.objects.create(
         agenda=agenda,
         start_datetime=now(),
-        repeat='daily',
+        recurrence_days=list(range(7)),
         places=5,
         recurrence_end_date=(now() + datetime.timedelta(days=5)).date(),
     )
@@ -1992,12 +1994,57 @@ def test_recurring_events_with_end_date():
 def test_recurring_events_sort(freezer):
     freezer.move_to('2021-01-06 12:00')  # Wednesday
     agenda = Agenda.objects.create(label='Agenda', kind='events')
-    Event.objects.create(agenda=agenda, slug='a', start_datetime=now(), repeat='daily', places=5)
-    Event.objects.create(agenda=agenda, slug='b', start_datetime=now(), repeat='daily', duration=10, places=5)
-    Event.objects.create(agenda=agenda, slug='c', start_datetime=now(), repeat='daily', duration=5, places=5)
     Event.objects.create(
-        agenda=agenda, slug='d', start_datetime=now() + datetime.timedelta(hours=1), repeat='daily', places=5
+        agenda=agenda, slug='a', start_datetime=now(), recurrence_days=list(range(7)), places=5
+    )
+    Event.objects.create(
+        agenda=agenda, slug='b', start_datetime=now(), recurrence_days=list(range(7)), duration=10, places=5
+    )
+    Event.objects.create(
+        agenda=agenda, slug='c', start_datetime=now(), recurrence_days=list(range(7)), duration=5, places=5
+    )
+    Event.objects.create(
+        agenda=agenda,
+        slug='d',
+        start_datetime=now() + datetime.timedelta(hours=1),
+        recurrence_days=list(range(7)),
+        places=5,
     )
 
     events = agenda.get_open_events()[:8]
     assert [e.primary_event.slug for e in events] == ['c', 'b', 'a', 'd', 'c', 'b', 'a', 'd']
+
+
+def test_recurring_events_display(freezer):
+    freezer.move_to('2021-01-06 12:30')
+    agenda = Agenda.objects.create(label='Agenda', kind='events')
+    event = Event.objects.create(
+        agenda=agenda, start_datetime=now(), recurrence_days=list(range(7)), places=5
+    )
+
+    assert event.get_recurrence_display() == 'Daily at 1:30 p.m.'
+
+    event.recurrence_days = [1, 2, 3, 4]
+    event.save()
+    assert event.get_recurrence_display() == 'From Tuesday to Friday at 1:30 p.m.'
+
+    event.recurrence_days = [4, 5, 6]
+    event.save()
+    assert event.get_recurrence_display() == 'From Friday to Sunday at 1:30 p.m.'
+
+    event.recurrence_days = [1, 4, 6]
+    event.save()
+    assert event.get_recurrence_display() == 'On Tuesday, Friday, Sunday at 1:30 p.m.'
+
+    event.recurrence_days = [0]
+    event.recurrence_week_interval = 2
+    event.save()
+    assert event.get_recurrence_display() == 'On Monday at 1:30 p.m., once every two weeks'
+
+    event.recurrence_week_interval = 3
+    event.recurrence_end_date = now() + datetime.timedelta(days=7)
+    event.save()
+    assert (
+        event.get_recurrence_display()
+        == 'On Monday at 1:30 p.m., once every three weeks, until Jan. 13, 2021'
+    )
diff --git a/tests/test_api.py b/tests/test_api.py
index bda0ffa..54df57a 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -299,7 +299,7 @@ def test_agendas_api(app):
         start_datetime=now(),
         places=10,
         agenda=event_agenda,
-        repeat='daily',
+        recurrence_days=list(range(7)),
     )
     assert len(event_agenda.get_open_events()) == 2
     resp = app.get('/api/agenda/', params={'with_open_events': '1'})
@@ -547,10 +547,11 @@ def test_datetimes_api_exclude_slots(app):
     event.delete()
 
     # recurrent event
+    start_datetime = localtime().replace(hour=12, minute=0)
     event = Event.objects.create(
         slug='recurrent',
-        start_datetime=localtime().replace(hour=12, minute=0),
-        repeat='weekly',
+        start_datetime=start_datetime,
+        recurrence_days=[start_datetime.weekday()],
         places=2,
         agenda=agenda,
     )
@@ -1257,10 +1258,11 @@ def test_booking_api_exclude_slots(app, user):
     event.delete()
 
     # recurrent event
+    start_datetime = localtime().replace(hour=12, minute=0)
     event = Event.objects.create(
         slug='recurrent',
-        start_datetime=localtime().replace(hour=12, minute=0),
-        repeat='weekly',
+        start_datetime=start_datetime,
+        recurrence_days=[start_datetime.weekday()],
         places=2,
         agenda=agenda,
     )
@@ -6080,7 +6082,12 @@ def test_recurring_events_api(app, user, freezer):
         label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30
     )
     base_event = Event.objects.create(
-        slug='abc', label='Test', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda
+        slug='abc',
+        label='Test',
+        start_datetime=localtime(),
+        recurrence_days=[localtime().weekday()],
+        places=5,
+        agenda=agenda,
     )
 
     resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
@@ -6148,7 +6155,11 @@ def test_recurring_events_api_various_times(app, user, mock_now):
         label='Foo bar', kind='events', minimal_booking_delay=0, maximal_booking_delay=30
     )
     event = Event.objects.create(
-        slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda
+        slug='abc',
+        start_datetime=localtime(),
+        recurrence_days=[localtime().weekday()],
+        places=5,
+        agenda=agenda,
     )
     event.refresh_from_db()
 
diff --git a/tests/test_import_export.py b/tests/test_import_export.py
index e8203db..742a2a2 100644
--- a/tests/test_import_export.py
+++ b/tests/test_import_export.py
@@ -199,7 +199,8 @@ def test_import_export_recurring_event(app, freezer):
     event = Event.objects.create(
         agenda=agenda,
         start_datetime=now(),
-        repeat='daily',
+        recurrence_days=list(range(7)),
+        recurrence_week_interval=2,
         places=10,
         slug='test',
     )
@@ -220,11 +221,12 @@ def test_import_export_recurring_event(app, freezer):
     assert Event.objects.count() == 1
     event = Agenda.objects.get(label='Foo Bar').event_set.first()
     assert event.primary_event is None
-    assert event.repeat == 'daily'
-    assert event.recurrence_rule == {'freq': DAILY}
+    assert event.recurrence_days == list(range(7))
+    assert event.recurrence_week_interval == 2
 
     # importing event with end recurrence date creates recurrences
     event.recurrence_end_date = now() + datetime.timedelta(days=7)
+    event.recurrence_week_interval = 1
     event.save()
     output = get_output_of_command('export_site')
     import_site(data={}, clean=True)
-- 
2.20.1

