add support for recurring events (#41663)

This commit is contained in:
Valentin Deniaud 2020-12-22 17:26:29 +01:00
parent a392213dce
commit a699e144b4
12 changed files with 574 additions and 13 deletions

View File

@ -0,0 +1,47 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.18 on 2021-02-16 14:53
from __future__ import unicode_literals
from django.db import migrations, models
import django.db.models.deletion
import jsonfield.fields
class Migration(migrations.Migration):
dependencies = [
('agendas', '0074_simple_desks'),
]
operations = [
migrations.AddField(
model_name='event',
name='primary_event',
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name='recurrences',
to='agendas.Event',
),
),
migrations.AddField(
model_name='event',
name='recurrence_rule',
field=jsonfield.fields.JSONField(null=True, verbose_name='Recurrence rule'),
),
migrations.AddField(
model_name='event',
name='repeat',
field=models.CharField(
blank=True,
choices=[
('daily', 'Daily'),
('weekly', 'Weekly'),
('2-weeks', 'Once every two weeks'),
('weekdays', 'Every weekdays (Monday to Friday)'),
],
max_length=16,
verbose_name='Repeat',
),
),
]

View File

@ -26,6 +26,8 @@ import uuid
import requests
import vobject
from dateutil.rrule import rrule, rruleset, DAILY, WEEKLY
from dateutil.relativedelta import relativedelta
import django
from django.conf import settings
@ -44,7 +46,7 @@ from django.utils.encoding import force_text
from django.utils.formats import date_format
from django.utils.module_loading import import_string
from django.utils.text import slugify
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware
from django.utils.timezone import localtime, now, make_aware, make_naive, is_aware, utc
from django.utils.translation import ugettext_lazy as _, ugettext, ungettext
from jsonfield import JSONField
@ -319,7 +321,7 @@ class Agenda(models.Model):
if self.kind == 'events':
agenda['default_view'] = self.default_view
agenda['booking_form_url'] = self.booking_form_url
agenda['events'] = [x.export_json() for x in self.event_set.all()]
agenda['events'] = [x.export_json() for x in self.event_set.filter(primary_event__isnull=True)]
if hasattr(self, 'notifications_settings'):
agenda['notifications_settings'] = self.notifications_settings.export_json()
elif self.kind == 'meetings':
@ -517,7 +519,10 @@ class Agenda(models.Model):
if prefetched_queryset:
entries = self.prefetched_events
else:
entries = self.event_set.filter(cancelled=False)
# recurring events are never opened
entries = self.event_set.filter(recurrence_rule__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.
entries = entries.filter(start_datetime__gte=localtime(now()))
# exclude non published events
@ -554,8 +559,42 @@ class Agenda(models.Model):
if annotate_queryset and not prefetched_queryset:
entries = Event.annotate_queryset(entries)
if max_start:
entries = self.add_event_recurrences(
entries,
min_start or localtime(now()),
max_start,
include_full=include_full,
prefetched_queryset=prefetched_queryset,
)
return entries
def add_event_recurrences(
self,
events,
min_start,
max_start,
include_full=True,
include_cancelled=False,
prefetched_queryset=False,
):
excluded_datetimes = [make_naive(event.start_datetime) for event in events]
events = [
e for e in events if (not e.cancelled or include_cancelled) and (not e.full or include_full)
]
if prefetched_queryset:
recurring_events = self.prefetched_recurring_events
else:
recurring_events = self.event_set.filter(recurrence_rule__isnull=False)
for event in recurring_events:
events.extend(event.get_recurrences(min_start, max_start, excluded_datetimes))
events.sort(key=lambda x: [getattr(x, field) for field in Event._meta.ordering])
return events
def get_booking_form_url(self):
if not self.booking_form_url:
return
@ -975,8 +1014,18 @@ 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)')),
]
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)
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)
publication_date = models.DateField(_('Publication date'), blank=True, null=True)
places = models.PositiveIntegerField(_('Places'))
@ -1029,6 +1078,7 @@ 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
@ -1151,6 +1201,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,
'places': self.places,
'waiting_list_places': self.waiting_list_places,
'label': self.label,
@ -1184,6 +1236,93 @@ class Event(models.Model):
self.cancelled = True
self.save()
def get_or_create_event_recurrence(self, start_datetime):
events = self.get_recurrences(start_datetime, start_datetime)
if len(events) == 0:
raise ValueError('No event recurrence found for specified datetime.')
elif len(events) > 1: # should not happen
raise ValueError('Multiple events found for specified datetime.')
event = events[0]
event.slug = event.slug.replace(':', '--')
with transaction.atomic():
try:
return Event.objects.get(agenda=self.agenda, slug=event.slug)
except Event.DoesNotExist:
event.save()
return event
def get_recurrences(self, min_datetime, max_datetime, excluded_datetimes=None):
recurrences = []
rrule_set = rruleset()
# do not generate recurrences for existing events
rrule_set._exdate = excluded_datetimes or []
event_base = Event(
agenda=self.agenda,
primary_event=self,
slug=self.slug,
duration=self.duration,
places=self.places,
waiting_list_places=self.waiting_list_places,
publication_date=self.publication_date,
label=self.label,
description=self.description,
pricing=self.pricing,
url=self.url,
)
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)))
# remove pytz info because dateutil doesn't support DST changes
min_datetime = make_naive(min_datetime)
max_datetime = make_naive(max_datetime)
rrule_set.rrule(rrule(dtstart=make_naive(self.start_datetime), **self.recurrence_rule))
for start_datetime in rrule_set.between(min_datetime, max_datetime, inc=True):
event = copy.copy(event_base)
# add timezone back
aware_start_datetime = make_aware(start_datetime)
event.slug = '%s:%s' % (event.slug, aware_start_datetime.strftime('%Y-%m-%d-%H%M'))
event.start_datetime = aware_start_datetime.astimezone(utc)
recurrences.append(event)
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,
}
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'] = [self.start_datetime.weekday()]
elif self.repeat == '2-weeks':
rrule['freq'] = WEEKLY
rrule['byweekday'] = [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
class BookingColor(models.Model):
COLOR_COUNT = 8

View File

@ -29,12 +29,12 @@ urlpatterns = [
),
url(r'^agenda/(?P<agenda_identifier>[\w-]+)/fillslots/$', views.fillslots, name='api-agenda-fillslots'),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w-]+)/$',
r'^agenda/(?P<agenda_identifier>[\w-]+)/status/(?P<event_identifier>[\w:-]+)/$',
views.slot_status,
name='api-event-status',
),
url(
r'^agenda/(?P<agenda_identifier>[\w-]+)/bookings/(?P<event_identifier>[\w-]+)/$',
r'^agenda/(?P<agenda_identifier>[\w-]+)/bookings/(?P<event_identifier>[\w:-]+)/$',
views.slot_bookings,
name='api-event-bookings',
),

View File

@ -365,6 +365,11 @@ def get_event_places(event):
def get_event_detail(request, event, agenda=None):
agenda = agenda or event.agenda
if event.label and event.primary_event is not None:
event.label = '%s (%s)' % (
event.label,
date_format(localtime(event.start_datetime), 'DATETIME_FORMAT'),
)
return {
'id': event.slug,
'slug': event.slug, # kept for compatibility
@ -416,6 +421,34 @@ def get_events_meta_detail(request, events, agenda=None):
}
def get_event_recurrence(agenda, event_identifier):
event_slug, datetime_str = event_identifier.split(':')
try:
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))
except ValueError:
raise APIError(
_('bad datetime format: %s') % datetime_str,
err_class='bad datetime format: %s' % datetime_str,
http_status=status.HTTP_400_BAD_REQUEST,
)
try:
event = agenda.event_set.get(slug=event_slug)
except Event.DoesNotExist:
raise APIError(
_('unknown recurring event slug: %s') % event_slug,
err_class='unknown recurring event slug:' % event_slug,
http_status=status.HTTP_400_BAD_REQUEST,
)
try:
return event.get_or_create_event_recurrence(start_datetime)
except ValueError:
raise APIError(
_('invalid datetime for event %s') % event_identifier,
err_class='invalid datetime for event %s' % event_identifier,
http_status=status.HTTP_400_BAD_REQUEST,
)
def get_resources_from_request(request, agenda):
if agenda.kind != 'meetings' or 'resources' not in request.GET:
return []
@ -458,12 +491,18 @@ class Agendas(APIView):
cancelled=False,
start_datetime__gte=localtime(now()),
).order_by()
recurring_event_queryset = Event.objects.filter(recurrence_rule__isnull=False)
agendas_queryset = agendas_queryset.filter(kind='events').prefetch_related(
Prefetch(
'event_set',
queryset=event_queryset,
to_attr='prefetched_events',
)
),
Prefetch(
'event_set',
queryset=recurring_event_queryset,
to_attr='prefetched_recurring_events',
),
)
agendas = []
@ -1032,6 +1071,13 @@ class Fillslots(APIView):
event.resources.add(*resources)
events.append(event)
else:
# convert event recurrence identifiers to real event slugs
for i, slot in enumerate(slots.copy()):
if not ':' in slot:
continue
event = get_event_recurrence(agenda, slot)
slots[i] = event.slug
try:
events = agenda.event_set.filter(id__in=[int(s) for s in slots]).order_by('start_datetime')
except ValueError:
@ -1568,6 +1614,8 @@ class SlotStatus(APIView):
agenda = Agenda.objects.get(pk=agenda_identifier, kind='events')
except (ValueError, Agenda.DoesNotExist):
raise Http404()
if ':' in event_identifier:
return get_event_recurrence(agenda, event_identifier)
try:
return agenda.event_set.get(slug=event_identifier)
except Event.DoesNotExist:
@ -1593,6 +1641,9 @@ class SlotBookings(APIView):
permission_classes = (permissions.IsAuthenticated,)
def get_object(self, agenda_identifier, event_identifier):
if ':' in event_identifier:
agenda = get_object_or_404(Agenda, slug=agenda_identifier, kind='events')
return get_event_recurrence(agenda, event_identifier)
return get_object_or_404(
Event, slug=event_identifier, agenda__slug=agenda_identifier, agenda__kind='events'
)

View File

@ -167,6 +167,7 @@ class NewEventForm(forms.ModelForm):
fields = [
'label',
'start_datetime',
'repeat',
'duration',
'places',
]
@ -185,6 +186,7 @@ class EventForm(forms.ModelForm):
'label',
'slug',
'start_datetime',
'repeat',
'duration',
'publication_date',
'places',
@ -586,7 +588,7 @@ class ImportEventsForm(forms.Form):
raise ValidationError(_('Invalid file format. (duration, line %d)') % (i + 1))
try:
event.full_clean(exclude=['desk', 'meeting_type'])
event.full_clean(exclude=['desk', 'meeting_type', 'primary_event'])
except ValidationError as e:
errors = [_('Invalid file format:\n')]
for label, field_errors in e.message_dict.items():

View File

@ -8,7 +8,7 @@
{% elif event.waiting_list_places %}
data-total="{{ event.waiting_list_places }}" data-booked="{{ event.waiting_list_count }}"
{% endif %}
><a href="{% if settings_view %}{% url 'chrono-manager-event-edit' pk=agenda.pk event_pk=event.pk %}?next=settings{% else %}{% url 'chrono-manager-event-view' pk=agenda.pk event_pk=event.pk %}{% endif %}">
><a href="{% if settings_view %}{% url 'chrono-manager-event-edit' pk=agenda.pk event_pk=event.pk %}?next=settings{% elif event.pk %}{% url 'chrono-manager-event-view' pk=agenda.pk event_pk=event.pk %}{% else %}{% url 'chrono-manager-event-create-recurrence' pk=agenda.pk event_identifier=event.slug %}{% endif %}">
{% if event.cancellation_status %}
<span class="tag">{{ event.cancellation_status }}</span>
{% elif event.main_list_full %}
@ -20,7 +20,11 @@
{% else %}
{% if event.label %}{{ event.label }} / {% endif %}
{% endif %}
{% if not event.repeat %}
{{ event.start_datetime }}
{% else %}
{{ event.get_recurrence_display }}
{% endif %}
{% if not settings_view %}
{% if event.places or event.waiting_list_places %}-{% endif %}
{% if event.places %}

View File

@ -175,6 +175,11 @@ urlpatterns = [
views.event_cancellation_report_list,
name='chrono-manager-event-cancellation-report-list',
),
url(
r'^agendas/(?P<pk>\d+)/create_event_recurrence/(?P<event_identifier>[\w:-]+)/$',
views.event_create_recurrence,
name='chrono-manager-event-create-recurrence',
),
url(
r'^agendas/(?P<pk>\d+)/add-resource/$',
views.agenda_add_resource,

View File

@ -50,6 +50,7 @@ from django.views.generic import (
TemplateView,
DayArchiveView,
MonthArchiveView,
RedirectView,
View,
)
@ -887,7 +888,7 @@ class AgendaDateView(DateMixin, ViewableAgendaMixin):
def get_queryset(self):
if self.agenda.kind == 'events':
queryset = self.agenda.event_set.all()
queryset = self.agenda.event_set.filter(recurrence_rule__isnull=True)
else:
self.agenda.prefetch_desks_and_exceptions()
if self.agenda.kind == 'meetings':
@ -1025,6 +1026,18 @@ class AgendaMonthView(AgendaDateView, MonthArchiveView):
return qs
return Event.annotate_queryset(qs).order_by('start_datetime', 'label')
def get_dated_items(self):
date_list, object_list, extra_context = super().get_dated_items()
if self.agenda.kind == 'events':
min_start = make_aware(datetime.datetime.combine(extra_context['month'], datetime.time(0, 0)))
max_start = make_aware(
datetime.datetime.combine(extra_context['next_month'], datetime.time(0, 0))
)
object_list = self.agenda.add_event_recurrences(
object_list, min_start, max_start, include_cancelled=True
)
return date_list, object_list, extra_context
def get_template_names(self):
if self.agenda.kind == 'virtual':
return ['chrono/manager_meetings_agenda_month_view.html']
@ -1384,7 +1397,7 @@ class AgendaSettings(ManagedAgendaMixin, DetailView):
return context
def get_events(self):
qs = self.agenda.event_set.all()
qs = self.agenda.event_set.filter(primary_event__isnull=True)
return Event.annotate_queryset(qs)
def get_template_names(self):
@ -2462,6 +2475,24 @@ class EventCancellationReportListView(ViewableAgendaMixin, ListView):
event_cancellation_report_list = EventCancellationReportListView.as_view()
class EventCreateRecurrenceView(ManagedAgendaMixin, RedirectView):
def get_redirect_url(self, *args, **kwargs):
event_slug, datetime_str = kwargs['event_identifier'].split(':')
try:
start_datetime = make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M'))
except ValueError:
raise Http404()
event = self.agenda.event_set.get(slug=event_slug)
try:
event_recurrence = event.get_or_create_event_recurrence(start_datetime)
except ValueError:
raise Http404()
return event_recurrence.get_absolute_view_url()
event_create_recurrence = EventCreateRecurrenceView.as_view()
class TimePeriodExceptionSourceToggleView(ManagedDeskSubobjectMixin, DetailView):
model = TimePeriodExceptionSource

View File

@ -1844,3 +1844,116 @@ def test_anonymize_bookings(freezer):
booking.refresh_from_db()
assert booking.label == 'hop'
assert not booking.anonymization_datetime
def test_recurring_events(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='weekly',
label='Event',
places=10,
waiting_list_places=10,
duration=10,
description='Description',
url='https://example.com',
pricing='10€',
)
event.refresh_from_db()
recurrences = event.get_recurrences(localtime(), localtime() + datetime.timedelta(days=15))
assert len(recurrences) == 3
first_event = recurrences[0]
assert first_event.slug == event.slug + ':2021-01-06-1300'
event_json = event.export_json()
first_event_json = first_event.export_json()
different_fields = ['slug', 'repeat', 'recurrence_rule']
assert all(first_event_json[k] == event_json[k] for k in event_json if k not in different_fields)
second_event = recurrences[1]
assert second_event.start_datetime == first_event.start_datetime + datetime.timedelta(days=7)
assert second_event.start_datetime.weekday() == first_event.start_datetime.weekday()
assert second_event.slug == 'event:2021-01-13-1300'
different_fields = ['slug', 'start_datetime']
second_event_json = second_event.export_json()
assert all(first_event_json[k] == second_event_json[k] for k in event_json if k not in different_fields)
new_recurrences = event.get_recurrences(
localtime() + datetime.timedelta(days=15),
localtime() + datetime.timedelta(days=30),
)
assert len(recurrences) == 3
assert new_recurrences[0].start_datetime == recurrences[-1].start_datetime + datetime.timedelta(days=7)
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.refresh_from_db()
dt = localtime()
recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8))
event_before_dst, event_after_dst = recurrences
assert event_before_dst.start_datetime.hour + 1 == event_after_dst.start_datetime.hour
assert event_before_dst.slug == 'agenda-event:2020-10-24-1400'
assert event_after_dst.slug == 'agenda-event:2020-10-31-1400'
freezer.move_to('2020-11-24 12:00')
new_recurrences = event.get_recurrences(dt, dt + datetime.timedelta(days=8))
new_event_before_dst, new_event_after_dst = new_recurrences
assert event_before_dst.start_datetime == new_event_before_dst.start_datetime
assert event_after_dst.start_datetime == new_event_after_dst.start_datetime
assert event_before_dst.slug == new_event_before_dst.slug
assert event_after_dst.slug == new_event_after_dst.slug
def test_recurring_events_repeat(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',
places=5,
)
event.refresh_from_db()
start_datetime = localtime(event.start_datetime)
freezer.move_to('2021-01-06 12:01') # recurrence on same day should not be returned
recurrences = event.get_recurrences(
localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7)
)
assert len(recurrences) == 6
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2)
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=7)
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.save()
recurrences = event.get_recurrences(
localtime() + datetime.timedelta(days=1), localtime() + datetime.timedelta(days=7)
)
assert len(recurrences) == 4
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=2)
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.save()
recurrences = event.get_recurrences(
localtime() + datetime.timedelta(days=3), localtime() + datetime.timedelta(days=45)
)
assert len(recurrences) == 3
assert recurrences[0].start_datetime == start_datetime + datetime.timedelta(days=14)
assert recurrences[-1].start_datetime == start_datetime + datetime.timedelta(days=14) * len(recurrences)
for i in range(len(recurrences) - 1):
assert (
recurrences[i].start_datetime + datetime.timedelta(days=14) == recurrences[i + 1].start_datetime
)

View File

@ -296,9 +296,20 @@ def test_agendas_api(app):
resp = app.get('/api/agenda/', params={'with_open_events': '1'})
assert len(resp.json['data']) == 0
# event recurrences are available
event = Event.objects.create(
start_datetime=now(),
places=10,
agenda=event_agenda,
repeat='daily',
)
assert len(event_agenda.get_open_events()) == 2
resp = app.get('/api/agenda/', params={'with_open_events': '1'})
assert len(resp.json['data']) == 1
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/api/agenda/', params={'with_open_events': '1'})
assert len(ctx.captured_queries) == 3
assert len(ctx.captured_queries) == 4
def test_agendas_meetingtypes_api(app, some_data, meetings_agenda):
@ -1691,7 +1702,7 @@ def test_booking_api_available(app, user):
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
assert len(ctx.captured_queries) == 3
assert len(ctx.captured_queries) == 4
assert resp.json['data'][-1]['places']['total'] == 20
assert resp.json['data'][-1]['places']['available'] == 20
assert resp.json['data'][-1]['places']['reserved'] == 0
@ -5234,3 +5245,93 @@ def test_datetimes_api_virtual_meetings_agenda_meta(app, freezer):
'bookable_datetimes_number_available': 0,
'first_bookable_slot': None,
}
def test_recurring_events_api(app, user, freezer):
freezer.move_to('2021-01-12 12:05') # Tuesday
agenda = Agenda.objects.create(
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
)
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
data = resp.json['data']
assert len(data) == 4
assert data[0]['id'] == 'abc:2021-01-19-1305'
assert data[0]['datetime'] == '2021-01-19 13:05:00'
assert data[0]['text'] == 'Test (Jan. 19, 2021, 1:05 p.m.)'
assert data[3]['id'] == 'abc:2021-02-09-1305'
assert Event.objects.count() == 1
fillslot_url = data[0]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
# book first event
resp = app.post(fillslot_url)
assert resp.json['err'] == 0
assert Event.objects.count() == 2
event = Booking.objects.get(pk=resp.json['booking_id']).event
assert event.slug == 'abc--2021-01-19-1305'
# first event is now a real event in datetimes
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
data = resp.json['data']
assert len(data) == 4
assert data[0]['id'] == event.slug
new_fillslot_url = data[0]['api']['fillslot_url']
# booking again with both old and new urls works
resp = app.post(fillslot_url)
assert resp.json['err'] == 0
resp = app.post(new_fillslot_url)
assert resp.json['err'] == 0
assert Event.objects.count() == 2
assert event.booking_set.count() == 3
# status and bookings api also create a real event
status_url = data[1]['api']['status_url']
resp = app.get(status_url)
assert resp.json['places']['total'] == 5
assert Event.objects.count() == 3
bookings_url = data[2]['api']['bookings_url']
resp = app.get(bookings_url, params={'user_external_id': '42'})
assert resp.json['data'] == []
assert Event.objects.count() == 4
# cancelled recurrences do not appear
event.cancel()
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
assert len(resp.json['data']) == 3
assert resp.json['data'][0]['id'] == 'abc--2021-01-26-1305'
# publication date is accounted for
Event.objects.filter(primary_event=base_event).delete()
base_event.publication_date = now().replace(day=27)
base_event.save()
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
assert len(resp.json['data']) == 2
assert resp.json['data'][0]['id'] == 'abc:2021-02-02-1305'
def test_recurring_events_api_various_times(app, user, mock_now):
agenda = Agenda.objects.create(
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
)
event.refresh_from_db()
resp = app.get('/api/agenda/%s/datetimes/' % agenda.slug)
assert len(resp.json['data']) == 5
fillslot_url = resp.json['data'][0]['api']['fillslot_url']
app.authorization = ('Basic', ('john.doe', 'password'))
resp = app.post(fillslot_url)
assert resp.json['err'] == 0
new_event = Booking.objects.get(pk=resp.json['booking_id']).event
assert event.start_datetime == new_event.start_datetime

View File

@ -13,6 +13,8 @@ import sys
import tempfile
import pytest
from dateutil.rrule import DAILY
from django.contrib.auth.models import Group
from django.core.management import call_command, CommandError
from django.test import override_settings
@ -190,6 +192,36 @@ def test_import_export_event_details(app):
assert first_imported_event.publication_date == datetime.date(2020, 5, 11)
def test_import_export_recurring_event(app, freezer):
freezer.move_to('2021-01-12 12:10')
agenda = Agenda.objects.create(label='Foo Bar', kind='events')
event = Event.objects.create(
agenda=agenda,
start_datetime=now(),
repeat='daily',
places=10,
)
event.refresh_from_db()
event.get_or_create_event_recurrence(event.start_datetime + datetime.timedelta(days=3))
assert Event.objects.count() == 2
output = get_output_of_command('export_site')
assert len(json.loads(output)['agendas']) == 1
import_site(data={}, clean=True)
with tempfile.NamedTemporaryFile() as f:
f.write(force_bytes(output))
f.flush()
call_command('import_site', f.name)
assert Agenda.objects.count() == 1
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}
def test_import_export_permissions(app):
meetings_agenda = Agenda.objects.create(label='Foo Bar', kind='meetings')
group1 = Group.objects.create(name=u'gé1')

View File

@ -3792,12 +3792,39 @@ def test_agenda_events_month_view(app, admin_user):
app.get(
'/manage/agendas/%s/%s/%s/' % (agenda.id, event.start_datetime.year, event.start_datetime.month)
)
assert len(ctx.captured_queries) == 6
assert len(ctx.captured_queries) == 7
# current month still doesn't have events
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, today.year, today.month))
assert "This month doesn't have any event configured." in resp.text
# add recurring event on every Wednesday
start_datetime = localtime().replace(day=7, month=10, year=2020)
event = Event.objects.create(
label='abc', start_datetime=start_datetime, places=10, agenda=agenda, repeat='weekly'
)
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 10))
assert len(resp.pyquery.find('.event-info')) == 4
assert 'abc' in resp.pyquery.find('.event-info')[0].text
# 12/2020 has 5 Wednesday
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 12))
assert len(resp.pyquery.find('.event-info')) == 5
# trying to access event recurrence creates it
event_count = Event.objects.count()
time = localtime(event.start_datetime).strftime('%H%M')
resp = resp.click(href='abc:2020-12-02-%s' % time)
assert Event.objects.count() == event_count + 1
Event.objects.get(slug='abc--2020-12-02-%s' % time).cancel()
resp = app.get('/manage/agendas/%s/%s/%s/' % (agenda.id, 2020, 12))
assert 'Cancelled' in resp.text
bad_event_url = '/manage/agendas/%s/create_event_recurrence/abc:2020-10-8-1130/' % agenda.id
resp = app.get(bad_event_url, status=404)
def test_agenda_open_events_view(app, admin_user, manager_user):
agenda = Agenda.objects.create(
@ -3846,6 +3873,14 @@ def test_agenda_open_events_view(app, admin_user, manager_user):
publication_date=today - datetime.timedelta(days=1),
places=42,
)
# weekly recurring event, first recurrence is in the past but second is in range
event = Event.objects.create(
label='event G',
start_datetime=now() - datetime.timedelta(days=3),
places=10,
agenda=agenda,
repeat='weekly',
)
resp = app.get('/manage/agendas/%s/events/open/' % agenda.pk)
assert 'event A' not in resp.text
assert 'event B' not in resp.text
@ -3853,6 +3888,7 @@ def test_agenda_open_events_view(app, admin_user, manager_user):
assert 'event D' in resp.text
assert 'event E' not in resp.text
assert 'event F' in resp.text
assert resp.text.count('event G') == 1
# event the first of February in 2 years at 00:00, already publicated
# and another event in January in 2 years