From 733edc34dea48dcbe241fc100fbdd5f050d224ea Mon Sep 17 00:00:00 2001 From: Valentin Deniaud Date: Wed, 21 Apr 2021 16:21:31 +0200 Subject: [PATCH 1/2] manager: add more granular control over event recurrence (#50560) --- .../commands/update_event_recurrences.py | 2 +- .../migrations/0091_auto_20210421_1556.py | 80 ++++++++++++ chrono/agendas/models.py | 119 +++++++++++------- chrono/api/views.py | 2 +- chrono/manager/forms.py | 66 ++++++++-- 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 | 8 +- chrono/manager/widgets.py | 13 +- tests/api/test_all.py | 6 +- tests/api/test_datetimes.py | 33 ++++- tests/api/test_fillslot.py | 5 +- tests/manager/test_all.py | 32 +++-- tests/manager/test_event.py | 40 +++--- tests/test_agendas.py | 97 ++++++++++---- tests/test_ensure_jsonbfields.py | 5 - tests/test_import_export.py | 8 +- 19 files changed, 416 insertions(+), 135 deletions(-) create mode 100644 chrono/agendas/migrations/0091_auto_20210421_1556.py create mode 100644 chrono/manager/templates/chrono/widgets/weekdays.html diff --git a/chrono/agendas/management/commands/update_event_recurrences.py b/chrono/agendas/management/commands/update_event_recurrences.py index 1e8606c..c7e3764 100644 --- a/chrono/agendas/management/commands/update_event_recurrences.py +++ b/chrono/agendas/management/commands/update_event_recurrences.py @@ -24,6 +24,6 @@ class Command(BaseCommand): help = 'Update event recurrences to reflect exceptions' def handle(self, **options): - agendas = Agenda.objects.filter(kind='events', event__recurrence_rule__isnull=False).distinct() + agendas = Agenda.objects.filter(kind='events', event__recurrence_days__isnull=False).distinct() for agenda in agendas: agenda.update_event_recurrences() diff --git a/chrono/agendas/migrations/0091_auto_20210421_1556.py b/chrono/agendas/migrations/0091_auto_20210421_1556.py new file mode 100644 index 0000000..6d85aac --- /dev/null +++ b/chrono/agendas/migrations/0091_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 dateutil.rrule import DAILY, WEEKLY +from django.db import migrations, models + + +def migrate_recurrence_fields(apps, schema_editor): + Event = apps.get_model('agendas', 'Event') + + for event in Event.objects.filter(recurrence_days__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', '0090_default_view'), + ] + + 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 0b2a525..438a57c 100644 --- a/chrono/agendas/models.py +++ b/chrono/agendas/models.py @@ -616,7 +616,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. @@ -687,7 +687,7 @@ class Agenda(models.Model): else: recurring_events = self.event_set.filter( Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()), - recurrence_rule__isnull=False, + recurrence_days__isnull=False, ) exceptions = self.get_recurrence_exceptions(min_start, max_start) @@ -705,7 +705,7 @@ class Agenda(models.Model): @transaction.atomic def update_event_recurrences(self): - recurring_events = self.event_set.filter(recurrence_rule__isnull=False) + recurring_events = self.event_set.filter(recurrence_days__isnull=False) recurrences = self.event_set.filter(primary_event__isnull=False) # remove recurrences @@ -1178,17 +1178,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, blank=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) @@ -1243,7 +1257,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 @@ -1267,7 +1280,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: @@ -1402,7 +1415,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 = { @@ -1431,8 +1444,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, @@ -1523,11 +1536,6 @@ class Event(models.Model): url=self.url, ) - 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) max_datetime = make_naive(max_datetime) @@ -1548,35 +1556,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 0de03eb..024e8c3 100644 --- a/chrono/api/views.py +++ b/chrono/api/views.py @@ -589,7 +589,7 @@ class Agendas(APIView): ).order_by() recurring_event_queryset = Event.objects.filter( Q(publication_date__isnull=True) | Q(publication_date__lte=localtime(now()).date()), - recurrence_rule__isnull=False, + recurrence_days__isnull=False, ) exceptions_desk = Desk.objects.filter(slug='_exceptions_holder').prefetch_related( 'unavailability_calendars' diff --git a/chrono/manager/forms.py b/chrono/manager/forms.py index e547924..47d9a66 100644 --- a/chrono/manager/forms.py +++ b/chrono/manager/forms.py @@ -53,7 +53,7 @@ from chrono.agendas.models import ( ) from . import widgets -from .widgets import SplitDateTimeField +from .widgets import SplitDateTimeField, WeekdaysWidget class AbsenceReasonForm(forms.ModelForm): @@ -160,22 +160,53 @@ 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', ] field_classes = { 'start_datetime': SplitDateTimeField, } + widgets = { + 'recurrence_end_date': forms.DateInput(attrs={'type': 'date'}, format='%Y-%m-%d'), + } + + 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 @@ -187,7 +218,9 @@ class EventForm(forms.ModelForm): 'label', 'slug', 'start_datetime', - 'repeat', + 'frequency', + 'recurrence_days', + 'recurrence_week_interval', 'recurrence_end_date', 'duration', 'publication_date', @@ -203,14 +236,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): @@ -219,18 +260,21 @@ 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: + protected_fields = list(self.protected_fields) + ['recurrence_end_date', 'frequency'] update_fields = { field: value for field, value in self.cleaned_data.items() - if field != 'recurrence_end_date' and field not in self.protected_fields + if field not in 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 c05e5cb..ce9db0e 100644 --- a/chrono/manager/static/css/style.scss +++ b/chrono/manager/static/css/style.scss @@ -442,3 +442,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 5dc12cf..ca63a52 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..e622c98 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 @@
{% csrf_token %} - {{ form.as_p }} + {{ form|with_template }}
{% trans 'Cancel' %}
+ +
{% 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..23a0a88 --- /dev/null +++ b/chrono/manager/templates/chrono/widgets/weekdays.html @@ -0,0 +1,10 @@ +{% spaceless %} + + {% for group, options, index in widget.optgroups %} + {% for option in options %} + {% include "django/forms/widgets/input.html" with widget=option %} + {{ option.label }} + {% endfor %} + {% endfor %} + +{% endspaceless %} diff --git a/chrono/manager/views.py b/chrono/manager/views.py index f8d2ebe..86c9893 100644 --- a/chrono/manager/views.py +++ b/chrono/manager/views.py @@ -1054,7 +1054,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': @@ -1583,7 +1583,7 @@ class AgendaSettings(ManagedAgendaMixin, DetailView): if self.agenda.kind == 'events': context['has_absence_reasons'] = AbsenceReasonGroup.objects.exists() context['has_recurring_events'] = self.agenda.event_set.filter( - recurrence_rule__isnull=False + recurrence_days__isnull=False ).exists() desk = Desk.objects.get(agenda=self.agenda, slug='_exceptions_holder') context['exceptions'] = TimePeriodException.objects.filter( @@ -1865,7 +1865,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) @@ -1900,7 +1900,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 296a8a1..75403ff 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 SplitDateTimeWidget, TimeInput +from django.forms.widgets import CheckboxSelectMultiple, SplitDateTimeWidget, TimeInput 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/api/test_all.py b/tests/api/test_all.py index 406a24a..cfcf870 100644 --- a/tests/api/test_all.py +++ b/tests/api/test_all.py @@ -224,7 +224,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'}) @@ -232,7 +232,9 @@ def test_agendas_api(app): for i in range(10): event_agenda = Agenda.objects.create(label='Foo bar', category=category_a) - Event.objects.create(start_datetime=now(), places=10, agenda=event_agenda, repeat='daily') + event = Event.objects.create( + start_datetime=now(), places=10, agenda=event_agenda, recurrence_days=[now().weekday()] + ) TimePeriodException.objects.create( desk=event_agenda.desk_set.get(), start_datetime=now(), diff --git a/tests/api/test_datetimes.py b/tests/api/test_datetimes.py index 047dc8d..a7babc0 100644 --- a/tests/api/test_datetimes.py +++ b/tests/api/test_datetimes.py @@ -193,10 +193,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, ) @@ -479,7 +480,12 @@ def test_datetimes_api_meta(app, freezer): # recurring event Event.objects.all().delete() 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_url) assert resp.json['meta']['first_bookable_slot']['text'] == 'Test (May 27, 2017, 1:12 a.m.)' @@ -491,7 +497,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) @@ -565,7 +576,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() @@ -606,7 +621,13 @@ def test_recurring_events_api_exceptions(app, user, freezer): agenda = Agenda.objects.create( label='Foo bar', kind='events', minimal_booking_delay=1, maximal_booking_delay=30 ) - Event.objects.create(slug='abc', start_datetime=localtime(), repeat='weekly', places=5, agenda=agenda) + Event.objects.create( + slug='abc', + start_datetime=localtime(), + recurrence_days=[localtime().weekday()], + places=5, + agenda=agenda, + ) resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug) data = resp.json['data'] diff --git a/tests/api/test_fillslot.py b/tests/api/test_fillslot.py index fce38b2..3d787ea 100644 --- a/tests/api/test_fillslot.py +++ b/tests/api/test_fillslot.py @@ -200,10 +200,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, ) diff --git a/tests/manager/test_all.py b/tests/manager/test_all.py index e2bc2c0..502e2d0 100644 --- a/tests/manager/test_all.py +++ b/tests/manager/test_all.py @@ -2659,12 +2659,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: @@ -2687,7 +2688,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 @@ -2727,7 +2732,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: @@ -2767,7 +2776,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 @@ -2850,12 +2863,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 @@ -4520,7 +4534,7 @@ def test_recurring_events_manage_exceptions(settings, app, admin_user, freezer): resp = app.get('/manage/agendas/%s/settings' % agenda.id) assert not 'Recurrence exceptions' in resp.text - event.repeat = 'daily' + event.recurrence_days = list(range(7)) event.save() resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2021, 7)) @@ -4564,7 +4578,7 @@ def test_recurring_events_exceptions_report(settings, app, admin_user, freezer): event = Event.objects.create( start_datetime=now(), places=10, - repeat='daily', + recurrence_days=list(range(7)), recurrence_end_date=now() + datetime.timedelta(days=30), agenda=agenda, ) diff --git a/tests/manager/test_event.py b/tests/manager/test_event.py index a1f397a..56df5d5 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,11 +251,11 @@ def test_edit_recurring_event(settings, app, admin_user, freezer): # but some fields should not be updated assert event_recurrence.slug != event.slug - assert not event_recurrence.repeat + assert not event_recurrence.recurrence_days # 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() @@ -272,7 +273,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 @@ -287,13 +290,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)) @@ -301,7 +313,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() @@ -345,12 +357,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') @@ -444,7 +450,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 0f4f303..75431c2 100644 --- a/tests/test_agendas.py +++ b/tests/test_agendas.py @@ -1879,7 +1879,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, @@ -1898,7 +1898,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] @@ -1922,7 +1922,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)) @@ -1940,22 +1942,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() @@ -1971,7 +1964,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) @@ -1981,7 +1974,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) @@ -1994,6 +1988,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(): @@ -2001,7 +2003,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(), ) @@ -2019,11 +2021,21 @@ 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] @@ -2043,7 +2055,7 @@ def test_recurring_events_exceptions(freezer): event = Event.objects.create( agenda=agenda, start_datetime=now(), - repeat='daily', + recurrence_days=list(range(7)), places=5, ) event.refresh_from_db() @@ -2116,14 +2128,14 @@ def test_recurring_events_exceptions_update_recurrences(freezer): daily_event = Event.objects.create( agenda=agenda, start_datetime=now(), - repeat='daily', + recurrence_days=list(range(7)), places=5, recurrence_end_date=datetime.date(year=2021, month=5, day=8), ) weekly_event = Event.objects.create( agenda=agenda, start_datetime=now(), - repeat='weekly', + recurrence_days=[now().weekday()], places=5, recurrence_end_date=datetime.date(year=2021, month=6, day=1), ) @@ -2132,7 +2144,7 @@ def test_recurring_events_exceptions_update_recurrences(freezer): daily_event_no_end_date = Event.objects.create( agenda=agenda, start_datetime=now() + datetime.timedelta(hours=2), - repeat='daily', + recurrence_days=list(range(7)), places=5, ) daily_event_no_end_date.refresh_from_db() @@ -2169,3 +2181,38 @@ def test_recurring_events_exceptions_update_recurrences(freezer): assert Booking.objects.count() == 1 assert Event.objects.filter(primary_event=daily_event_no_end_date).count() == 1 assert agenda.recurrence_exceptions_report.events.get() == event + + +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_ensure_jsonbfields.py b/tests/test_ensure_jsonbfields.py index 2d96fdb..28c69eb 100644 --- a/tests/test_ensure_jsonbfields.py +++ b/tests/test_ensure_jsonbfields.py @@ -12,7 +12,6 @@ def test_ensure_jsonb_fields(): json_fields = ( 'extra_data', 'booking_errors', - 'recurrence_rule', ) with connection.cursor() as cursor: @@ -34,10 +33,6 @@ def test_ensure_jsonb_fields(): '''ALTER TABLE agendas_eventcancellationreport ALTER COLUMN booking_errors TYPE text USING booking_errors::text''' ) - cursor.execute( - '''ALTER TABLE agendas_event - ALTER COLUMN recurrence_rule TYPE text USING recurrence_rule::text''' - ) call_command('ensure_jsonb') diff --git a/tests/test_import_export.py b/tests/test_import_export.py index 0bb036f..67e36e0 100644 --- a/tests/test_import_export.py +++ b/tests/test_import_export.py @@ -214,7 +214,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', ) @@ -235,11 +236,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