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()