From 589f51454e7bbf2b46f6a35a068a10fe524dda27 Mon Sep 17 00:00:00 2001 From: Phin Wolkwitz Date: Wed, 29 Apr 2026 11:59:06 +0200 Subject: [PATCH] Add locations to program times (Z#23221129) Add location for program time slots and extend .ical and PDF placeholder --- doc/api/resources/item_program_times.rst | 19 +++-- src/pretix/api/serializers/item.py | 4 +- .../0299_itemprogramtime_location.py | 19 +++++ src/pretix/base/models/items.py | 7 ++ src/pretix/base/pdf.py | 24 +++--- src/pretix/control/forms/item.py | 7 +- .../item/include_program_times.html | 2 + src/pretix/presale/ical.py | 2 +- src/tests/api/test_items.py | 78 ++++++++++++++++++- src/tests/base/test_event_clone.py | 7 +- src/tests/control/test_items.py | 4 +- 11 files changed, 151 insertions(+), 22 deletions(-) create mode 100644 src/pretix/base/migrations/0299_itemprogramtime_location.py diff --git a/doc/api/resources/item_program_times.rst b/doc/api/resources/item_program_times.rst index db8a6d3368..19a1885183 100644 --- a/doc/api/resources/item_program_times.rst +++ b/doc/api/resources/item_program_times.rst @@ -16,6 +16,7 @@ Field Type Description id integer Internal ID of the program time start datetime The start date time for this program time slot. end datetime The end date time for this program time slot. +location multi-lingual string The program time slot's location (or ``null``) ===================================== ========================== ======================================================= .. versionchanged:: TODO @@ -54,17 +55,20 @@ Endpoints { "id": 2, "start": "2025-08-14T22:00:00Z", - "end": "2025-08-15T00:00:00Z" + "end": "2025-08-15T00:00:00Z", + "location": null }, { "id": 3, "start": "2025-08-12T22:00:00Z", - "end": "2025-08-13T22:00:00Z" + "end": "2025-08-13T22:00:00Z", + "location": null }, { "id": 14, "start": "2025-08-15T22:00:00Z", - "end": "2025-08-17T22:00:00Z" + "end": "2025-08-17T22:00:00Z", + "location": null } ] } @@ -99,7 +103,8 @@ Endpoints { "id": 1, "start": "2025-08-15T22:00:00Z", - "end": "2025-10-27T23:00:00Z" + "end": "2025-10-27T23:00:00Z", + "location": null } :param organizer: The ``slug`` field of the organizer to fetch @@ -125,7 +130,8 @@ Endpoints { "start": "2025-08-15T10:00:00Z", - "end": "2025-08-15T22:00:00Z" + "end": "2025-08-15T22:00:00Z", + "location": null } **Example response**: @@ -139,7 +145,8 @@ Endpoints { "id": 17, "start": "2025-08-15T10:00:00Z", - "end": "2025-08-15T22:00:00Z" + "end": "2025-08-15T22:00:00Z", + "location": null } :param organizer: The ``slug`` field of the organizer of the event/item to create a program time for diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index a2c6258f5e..35750c02df 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -191,7 +191,7 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer): class InlineItemProgramTimeSerializer(serializers.ModelSerializer): class Meta: model = ItemProgramTime - fields = ('start', 'end') + fields = ('start', 'end', 'location') class ItemBundleSerializer(serializers.ModelSerializer): @@ -222,7 +222,7 @@ class ItemBundleSerializer(serializers.ModelSerializer): class ItemProgramTimeSerializer(serializers.ModelSerializer): class Meta: model = ItemProgramTime - fields = ('id', 'start', 'end') + fields = ('id', 'start', 'end', 'location') def validate(self, data): data = super().validate(data) diff --git a/src/pretix/base/migrations/0299_itemprogramtime_location.py b/src/pretix/base/migrations/0299_itemprogramtime_location.py new file mode 100644 index 0000000000..390b33d72d --- /dev/null +++ b/src/pretix/base/migrations/0299_itemprogramtime_location.py @@ -0,0 +1,19 @@ +# Generated by Django 4.2.27 on 2026-01-21 12:06 + +import i18nfield.fields +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0298_pluggable_permissions"), + ] + + operations = [ + migrations.AddField( + model_name="itemprogramtime", + name="location", + field=i18nfield.fields.I18nTextField(max_length=200, null=True), + ) + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 339ad45014..feb7dec8a5 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -2306,10 +2306,17 @@ class ItemProgramTime(models.Model): :type start: datetime :param end: The date and time this program time ends :type end: datetime + :param location: venue + :type location: str """ item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE) start = models.DateTimeField(verbose_name=_("Start")) end = models.DateTimeField(verbose_name=_("End")) + location = I18nTextField( + null=True, blank=True, + max_length=200, + verbose_name=_("Location"), + ) def clean(self): if hasattr(self, 'item') and self.item and self.item.event.has_subevents: diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index 8a91c8228e..a8cb2fc2c3 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -498,9 +498,9 @@ DEFAULT_VARIABLES = OrderedDict(( ) if op.valid_until else "" }), ("program_times", { - "label": _("Program times: date and time"), + "label": _("Program times"), "editor_sample": _( - "2017-05-31 10:00 – 12:00\n2017-05-31 14:00 – 16:00\n2017-05-31 14:00 – 2017-06-01 14:00"), + "2017-05-31 10:00 – 12:00, Room 1\n2017-05-31 14:00 – 16:00, Room 2\n2017-05-31 14:00 – 2017-06-01 14:00, Building A"), "evaluate": lambda op, order, ev: get_program_times(op, ev) }), ("medium_identifier", { @@ -748,13 +748,19 @@ def get_seat(op: OrderPosition): def get_program_times(op: OrderPosition, ev: Event): - return '\n'.join([ - datetimerange( - pt.start.astimezone(ev.timezone), - pt.end.astimezone(ev.timezone), - as_html=False - ) for pt in op.item.program_times.all() - ]) + ptstr = [] + for pt in op.item.program_times.all(): + ptstr.append([ + datetimerange( + pt.start.astimezone(ev.timezone), + pt.end.astimezone(ev.timezone), + as_html=False + ), + (', ' + ', '.join( + l.strip() for l in str(pt.location).splitlines() if l.strip()) + ) if str(pt.location).strip() else '' + ]) + return '\n'.join(''.join(l) for l in ptstr) def generate_compressed_addon_list(op, order, event, only_checked_in=False): diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 6d5b8f1797..6829907cd4 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -574,7 +574,7 @@ class ItemCreateForm(I18nModelForm): instance.bundles.create(bundled_item=b.bundled_item, bundled_variation=b.bundled_variation, count=b.count, designated_price=b.designated_price) for pt in self.cleaned_data['copy_from'].program_times.all(): - instance.program_times.create(start=pt.start, end=pt.end) + instance.program_times.create(start=pt.start, end=pt.end, location=pt.location) item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance) @@ -1354,6 +1354,10 @@ class ItemProgramTimeForm(I18nModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['end'].widget.attrs['data-date-after'] = '#id_{prefix}-start_0'.format(prefix=self.prefix) + self.fields['location'].widget.attrs['rows'] = '3' + self.fields['location'].widget.attrs['placeholder'] = _( + 'Sample Conference Center, Heidelberg, Germany' + ) class Meta: model = ItemProgramTime @@ -1361,6 +1365,7 @@ class ItemProgramTimeForm(I18nModelForm): fields = [ 'start', 'end', + 'location' ] field_classes = { 'start': forms.SplitDateTimeField, diff --git a/src/pretix/control/templates/pretixcontrol/item/include_program_times.html b/src/pretix/control/templates/pretixcontrol/item/include_program_times.html index e223a0eecb..9939cbf4fd 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_program_times.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_program_times.html @@ -34,6 +34,7 @@ {% bootstrap_form_errors form %} {% bootstrap_field form.start layout="control" %} {% bootstrap_field form.end layout="control" %} + {% bootstrap_field form.location layout="control" %} {% endfor %} @@ -59,6 +60,7 @@
{% bootstrap_field formset.empty_form.start layout="control" %} {% bootstrap_field formset.empty_form.end layout="control" %} + {% bootstrap_field formset.empty_form.location layout="control" %}
{% endescapescript %} diff --git a/src/pretix/presale/ical.py b/src/pretix/presale/ical.py index 14a1ec9ea8..ba0571f244 100644 --- a/src/pretix/presale/ical.py +++ b/src/pretix/presale/ical.py @@ -153,7 +153,7 @@ def get_private_icals(event, positions): # Actual ical organizer field is not useful since it will cause "your invitation was accepted" emails to the organizer descr.append(_('Organizer: {organizer}').format(organizer=event.organizer.name)) description = '\n'.join(descr) - location = None + location = ", ".join(l.strip() for l in str(pt.location).splitlines() if l.strip()) dtstart = pt.start.astimezone(tz) dtend = pt.end.astimezone(tz) uid = 'pretix-{}-{}-{}-{}@{}'.format( diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 25ea7cea88..b2aef4737c 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -530,6 +530,7 @@ def test_item_detail_program_times(token_client, organizer, event, team, item, c res["program_times"] = [{ "start": "2017-12-27T00:00:00Z", "end": "2017-12-28T00:00:00Z", + "location": None }] resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk)) @@ -1972,32 +1973,54 @@ def program_time2(item, category): end=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc)) +@pytest.fixture +def program_time3(item, category): + return item.program_times.create(start=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc), + end=datetime(2017, 12, 31, 0, 0, 0, tzinfo=timezone.utc), + location='Testlocation') + + TEST_PROGRAM_TIMES_RES = { 0: { "start": "2017-12-27T00:00:00Z", "end": "2017-12-28T00:00:00Z", + "location": None, }, 1: { "start": "2017-12-29T00:00:00Z", "end": "2017-12-30T00:00:00Z", + "location": None, + }, + 2: { + "start": "2017-12-30T00:00:00Z", + "end": "2017-12-31T00:00:00Z", + "location": {"en": "Testlocation"}, } } @pytest.mark.django_db -def test_program_times_list(token_client, organizer, event, item, program_time, program_time2): +def test_program_times_list(token_client, organizer, event, item, program_time, program_time2, program_time3): res = dict(TEST_PROGRAM_TIMES_RES) res[0]["id"] = program_time.pk res[1]["id"] = program_time2.pk + res[2]["id"] = program_time3.pk resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk)) assert resp.status_code == 200 assert res[0]['start'] == resp.data['results'][0]['start'] assert res[0]['end'] == resp.data['results'][0]['end'] assert res[0]['id'] == resp.data['results'][0]['id'] + assert res[0] == resp.data['results'][0] assert res[1]['start'] == resp.data['results'][1]['start'] assert res[1]['end'] == resp.data['results'][1]['end'] assert res[1]['id'] == resp.data['results'][1]['id'] + assert res[1] == resp.data['results'][1] + assert res[2]['start'] == resp.data['results'][2]['start'] + assert res[2]['end'] == resp.data['results'][2]['end'] + assert res[2]['location'] == resp.data['results'][2]['location'] + assert res[2]['id'] == resp.data['results'][2]['id'] + assert res[2] == resp.data['results'][2] @pytest.mark.django_db @@ -2039,6 +2062,59 @@ def test_program_times_create(token_client, organizer, event, item): assert resp.content.decode() == '{"non_field_errors":["The program end must not be before the program start."]}' +@pytest.mark.django_db +def test_program_times_create_location(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk), + { + "start": "2017-12-27T00:00:00Z", + "end": "2017-12-28T00:00:00Z", + "location": { + "en": "Testlocation", + "de": "Testort" + } + }, + format='json' + ) + assert resp.status_code == 201 + with scopes_disabled(): + program_time = ItemProgramTime.objects.get(pk=resp.data['id']) + assert "Testlocation" == program_time.location.localize("en") + assert "Testort" == program_time.location.localize("de") + + +@pytest.mark.django_db +def test_program_times_create_without_location(token_client, organizer, event, item): + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk), + { + "start": "2017-12-27T00:00:00Z", + "end": "2017-12-28T00:00:00Z" + }, + format='json' + ) + assert resp.status_code == 201 + assert resp.data['location'] is None + with scopes_disabled(): + program_time = ItemProgramTime.objects.get(pk=resp.data['id']) + assert str(program_time.location) == "" + + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk), + { + "start": "2017-12-27T00:00:00Z", + "end": "2017-12-28T00:00:00Z", + "location": None + }, + format='json' + ) + assert resp.status_code == 201 + assert resp.data['location'] is None + with scopes_disabled(): + program_time = ItemProgramTime.objects.get(pk=resp.data['id']) + assert str(program_time.location) == "" + + @pytest.mark.django_db def test_program_times_update(token_client, organizer, event, item, program_time): resp = token_client.patch( diff --git a/src/tests/base/test_event_clone.py b/src/tests/base/test_event_clone.py index 784bd079a4..a003bf54ec 100644 --- a/src/tests/base/test_event_clone.py +++ b/src/tests/base/test_event_clone.py @@ -82,7 +82,11 @@ def test_full_clone_same_organizer(): assert item1.meta_data ItemProgramTime.objects.create(item=item1, start=datetime.datetime(2017, 12, 27, 0, 0, 0, tzinfo=datetime.timezone.utc), - end=datetime.datetime(2017, 12, 28, 0, 0, 0, tzinfo=datetime.timezone.utc)) + end=datetime.datetime(2017, 12, 28, 0, 0, 0, tzinfo=datetime.timezone.utc), + location={ + "en": "Testlocation", + "de": "Testort" + }) assert item1.program_times item2 = event.items.create(category=category, tax_rule=tax_rule, name="T-shirt", default_price=15, hidden_if_item_available=item1) @@ -169,6 +173,7 @@ def test_full_clone_same_organizer(): assert copied_item1.meta_data == item1.meta_data assert copied_item1.program_times.first().start == item1.program_times.first().start assert copied_item1.program_times.first().end == item1.program_times.first().end + assert copied_item1.program_times.first().location == item1.program_times.first().location assert copied_item2.variations.get().meta_data == item2v.meta_data assert copied_item1.hidden_if_available == copied_q2 assert copied_item1.grant_membership_type == membership_type diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index d7d37eaaac..4d274e8ca6 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -692,7 +692,8 @@ class ItemsTest(ItemFormTest): self.item2.program_times.create(start=datetime.datetime(2017, 12, 27, 0, 0, 0, tzinfo=datetime.timezone.utc), end=datetime.datetime(2017, 12, 28, 0, 0, 0, - tzinfo=datetime.timezone.utc)) + tzinfo=datetime.timezone.utc), + location={"en": "Testlocation", "de": "Testort"}) doc = self.get_doc('/control/event/%s/%s/items/add?copy_from=%d' % (self.orga1.slug, self.event1.slug, self.item2.pk)) data = extract_form_fields(doc.select("form")[0]) @@ -723,6 +724,7 @@ class ItemsTest(ItemFormTest): assert set([str(v.value) for v in i_new.variations.all()]) == set([str(v.value) for v in i_old.variations.all()]) assert i_old.program_times.first().start == i_new.program_times.first().start assert i_old.program_times.first().end == i_new.program_times.first().end + assert i_old.program_times.first().location == i_new.program_times.first().location def test_add_to_existing_quota(self): with scopes_disabled():