Add locations to program times (Z#23221129)

Add location for program time slots and extend .ical and PDF placeholder
This commit is contained in:
Phin Wolkwitz
2026-04-29 11:59:06 +02:00
committed by GitHub
parent bda27d72e7
commit 589f51454e
11 changed files with 151 additions and 22 deletions

View File

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

View File

@@ -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),
)
]

View File

@@ -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:

View File

@@ -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):

View File

@@ -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,

View File

@@ -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" %}
</div>
</div>
{% endfor %}
@@ -59,6 +60,7 @@
<div class="panel-body form-horizontal">
{% bootstrap_field formset.empty_form.start layout="control" %}
{% bootstrap_field formset.empty_form.end layout="control" %}
{% bootstrap_field formset.empty_form.location layout="control" %}
</div>
</div>
{% endescapescript %}

View File

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

View File

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

View File

@@ -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

View File

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