add support for recurring events (#41663)
This commit is contained in:
parent
a392213dce
commit
a699e144b4
|
@ -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',
|
||||
),
|
||||
),
|
||||
]
|
|
@ -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
|
||||
|
|
|
@ -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',
|
||||
),
|
||||
|
|
|
@ -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'
|
||||
)
|
||||
|
|
|
@ -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():
|
||||
|
|
|
@ -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 %}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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
|
||||
|
||||
|
|
|
@ -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
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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')
|
||||
|
|
|
@ -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
|
||||
|
|
Loading…
Reference in New Issue