diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index b0a78fd7f4..05607956e3 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -28,7 +28,7 @@ from django.forms import formset_factory from django.forms.utils import ErrorDict from django.urls import reverse from django.utils.functional import cached_property -from django.utils.translation import gettext_lazy as _ +from django.utils.translation import gettext_lazy as _, pgettext_lazy from i18nfield.forms import I18nInlineFormSet from pretix.base.forms import I18nModelForm @@ -102,6 +102,16 @@ class SubEventBulkForm(SubEventForm): required=False, limit_choices=('date_from', 'date_to'), ) + skip_if_overlap = forms.BooleanField( + label=pgettext_lazy('subevent', 'Skip dates that overlap with any existing date'), + help_text=pgettext_lazy( + 'subevent', + 'This can be useful if all your dates happen in the same location and no repeated dates should ' + 'be created in conflict with existing special events. This respects even inactive dates and works best if ' + 'all dates have both a start and end time.' + ), + required=False, + ) def __init__(self, *args, **kwargs): self.event = kwargs['event'] diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html index 3f7a041f79..97a0e74d48 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html @@ -379,6 +379,8 @@ {% trans "Add many time slots" %}

+
+ {% bootstrap_field form.skip_if_overlap layout="control" horizontal_label_class='sr-only' horizontal_field_class='col-md-12' %}
{% trans "General information" %} diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index 4ecf6a8237..2477231ce3 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -917,6 +917,35 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Asyn if len(subevents) > 100_000: raise ValidationError(_('Please do not create more than 100.000 dates at once.')) + if form.cleaned_data.get("skip_if_overlap") and subevents: + def overlaps(a_from, a_to, b_from, b_to): + if a_from == b_from: + return True + if a_from > b_from: + # a starts after b + # check if it starts before b ends + return b_to and a_from < b_to + # a starts before b + # check if it ends before b starts + return a_to and a_to > b_from + + date_min = min(se.date_from for se in subevents) + date_max = max(se.date_to or se.date_from for se in subevents) + dates_existing = list(self.request.event.subevents.annotate( + date_fromto=Coalesce('date_to', 'date_from'), + ).filter( + date_from__lte=date_max, + date_fromto__gte=date_min, + ).values('date_from', 'date_to')) + subevents = [ + se for se in subevents if not any( + overlaps(se.date_from, se.date_to, other['date_from'], other['date_to']) + for other in dates_existing + ) + ] + if not subevents: + raise ValidationError(_('All dates would be skipped because they conflict with existing dates.')) + for i, se in enumerate(subevents): se.save(clear_cache=False) if i % 100 == 0: diff --git a/src/tests/control/test_subevents.py b/src/tests/control/test_subevents.py index 273f1b3c17..5e58e0ebf4 100644 --- a/src/tests/control/test_subevents.py +++ b/src/tests/control/test_subevents.py @@ -747,6 +747,92 @@ class SubEventsTest(SoupTest): assert ses[1].date_from.isoformat() == "2018-04-12T11:29:31+00:00" assert ses[-1].date_from.isoformat() == "2019-03-28T12:29:31+00:00" + def test_create_bulk_skip_existing(self): + with scopes_disabled(): + self.event1.subevents.all().delete() + # SubEvent ends at rrule start time + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 4, 9, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 4, 10, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent overlaps rrule start + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 5, 9, 30, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 5, 10, 30, tzinfo=datetime.timezone.utc), + ) + # SubEvent times are same as rrule + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 6, 10, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 6, 11, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent starts at rrule end time + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 7, 11, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 7, 12, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent overlaps entire rrule time + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 8, 9, 0, tzinfo=datetime.timezone.utc), + date_to=datetime.datetime(2018, 4, 8, 12, 0, tzinfo=datetime.timezone.utc), + ) + # SubEvent has before rrule time and no end + self.event1.subevents.create( + date_from=datetime.datetime(2018, 4, 9, 9, 0, tzinfo=datetime.timezone.utc), + ) + existing_events = list(self.event1.subevents.values_list('pk', flat=True)) + + self.event1.settings.timezone = 'Europe/Berlin' + doc = self.post_doc('/control/event/ccc/30c3/subevents/bulk_add', { + 'rruleformset-TOTAL_FORMS': '1', + 'rruleformset-INITIAL_FORMS': '0', + 'rruleformset-MIN_NUM_FORMS': '0', + 'rruleformset-MAX_NUM_FORMS': '1000', + 'rruleformset-0-end': 'count', + 'rruleformset-0-count': '10', + 'rruleformset-0-interval': '1', + 'rruleformset-0-freq': 'weekly', + 'rruleformset-0-dtstart': '2018-04-03', + 'rruleformset-0-weekly_byweekday': ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'], + 'rruleformset-0-yearly_same': 'on', + 'rruleformset-0-monthly_same': 'on', + 'timeformset-TOTAL_FORMS': '1', + 'timeformset-INITIAL_FORMS': '0', + 'timeformset-MIN_NUM_FORMS': '1', + 'timeformset-MAX_NUM_FORMS': '1000', + 'timeformset-0-time_from': '12:00:00', + 'timeformset-0-time_to': '13:00:00', + 'rruleformset-0-until': '2019-04-03', + 'skip_if_overlap': 'on', + 'name_0': 'Foo', + 'active': 'on', + 'frontpage_text_0': '', + 'quotas-TOTAL_FORMS': '1', + 'quotas-INITIAL_FORMS': '0', + 'quotas-MIN_NUM_FORMS': '0', + 'quotas-MAX_NUM_FORMS': '1000', + 'quotas-0-name': 'Q1', + 'quotas-0-size': '50', + 'quotas-0-itemvars': str(self.ticket.pk), + 'checkinlist_set-TOTAL_FORMS': '0', + 'checkinlist_set-INITIAL_FORMS': '0', + 'checkinlist_set-MIN_NUM_FORMS': '0', + 'checkinlist_set-MAX_NUM_FORMS': '1000', + }) + assert doc.select(".alert-success") + with scopes_disabled(): + ses = list(self.event1.subevents.exclude(pk__in=existing_events).order_by('date_from')) + + assert len(ses) == 7 + assert [s.date_from.date().isoformat() for s in ses] == [ + '2018-04-03', + '2018-04-04', + '2018-04-07', + '2018-04-09', + '2018-04-10', + '2018-04-11', + '2018-04-12' + ] + def test_delete_bulk(self): self.subevent2.active = True self.subevent2.save()