Add program times for items (Z#23178639)

* Add program times for items

* Fix frontend date validation

* Add ical data for program times [wip]

* Improve ical data for program times

* Remove duplicate code and add comments

* Adjust migration

* Remove program times form for event series

* Add pdf placeholder [wip]

* Improve explanation text with suggestion

Co-authored-by: Raphael Michel <michel@pretix.eu>

* Fix import sorting

* Improve ical generation

* Improve ical entry description

* Fix migration

* Add copyability for program times fot items and events

* Update migration

* Add API endpoints/functions, fix isort

* Improve variable name

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Remove todo comment

* Add documentation, Change endpoint name

* Change related name

* Remove unnecessary code block

* Add program times to item API

* Fix imports

* Add log text

* Use daterange helper

* Add and update API tests

* Add another API test

* Add program times to cloning tests

* Update query count because of program times query

* Invalidate cached tickets on program time changes

* Reduce invalidation calls

* Update migration after rebase

* Apply improvements to invalidation from review

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* remove unneccessary attr=item param

* remove unnecessary kwargs for formset_factory

* fix local var name being overwritten in for-loop

* fix empty formset being saved

* Use subevent if available

* make code less verbose

* remove double event-label in ical desc

* fix unnecessary var re-assign

* fix ev vs p.subevent

---------

Co-authored-by: Raphael Michel <michel@pretix.eu>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
This commit is contained in:
Phin Wolkwitz
2025-11-06 12:24:47 +01:00
committed by GitHub
parent 7041d40972
commit fd9d03786b
20 changed files with 903 additions and 89 deletions

View File

@@ -46,7 +46,8 @@ from tests.const import SAMPLE_PNG
from pretix.base.models import (
CartPosition, InvoiceAddress, Item, ItemAddOn, ItemBundle, ItemCategory,
ItemVariation, Order, OrderPosition, Question, QuestionOption, Quota,
ItemProgramTime, ItemVariation, Order, OrderPosition, Question,
QuestionOption, Quota,
)
from pretix.base.models.orders import OrderFee
@@ -331,6 +332,7 @@ TEST_ITEM_RES = {
"variations": [],
"addons": [],
"bundles": [],
"program_times": [],
"show_quota_left": None,
"original_price": None,
"free_price_suggestion": None,
@@ -509,6 +511,24 @@ def test_item_detail_bundles(token_client, organizer, event, team, item, categor
assert res == resp.data
@pytest.mark.django_db
def test_item_detail_program_times(token_client, organizer, event, team, item, category):
with scopes_disabled():
item.program_times.create(item=item, start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc))
res = dict(TEST_ITEM_RES)
res["id"] = item.pk
res["program_times"] = [{
"start": "2017-12-27T00:00:00Z",
"end": "2017-12-28T00:00:00Z",
}]
resp = token_client.get('/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug,
item.pk))
assert resp.status_code == 200
assert res == resp.data
@pytest.mark.django_db
def test_item_create(token_client, organizer, event, item, category, taxrule, membership_type):
resp = token_client.post(
@@ -1072,6 +1092,57 @@ def test_item_create_with_bundle(token_client, organizer, event, item, category,
assert resp.content.decode() == '{"bundles":["The chosen variation does not belong to this item."]}'
@pytest.mark.django_db
def test_item_create_with_product_time(token_client, organizer, event, item, category, taxrule):
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/items/'.format(organizer.slug, event.slug),
{
"category": category.pk,
"name": {
"en": "Ticket"
},
"active": True,
"description": None,
"default_price": "23.00",
"free_price": False,
"tax_rate": "19.00",
"tax_rule": taxrule.pk,
"admission": True,
"issue_giftcard": False,
"position": 0,
"picture": None,
"available_from": None,
"available_until": None,
"require_voucher": False,
"hide_without_voucher": False,
"allow_cancel": True,
"min_per_order": None,
"max_per_order": None,
"checkin_attention": False,
"checkin_text": None,
"has_variations": False,
"program_times": [
{
"start": "2017-12-27T00:00:00Z",
"end": "2017-12-28T00:00:00Z",
},
{
"start": "2017-12-29T00:00:00Z",
"end": "2017-12-30T00:00:00Z",
}
]
},
format='json'
)
assert resp.status_code == 201
with scopes_disabled():
new_item = Item.objects.get(pk=resp.data['id'])
assert new_item.program_times.first().start == datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc)
assert new_item.program_times.first().end == datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc)
assert new_item.program_times.last().start == datetime(2017, 12, 29, 0, 0, 0, tzinfo=timezone.utc)
assert new_item.program_times.last().end == datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc)
@pytest.mark.django_db(transaction=True)
def test_item_update(token_client, organizer, event, item, category, item2, category2, taxrule2):
resp = token_client.patch(
@@ -1147,8 +1218,8 @@ def test_item_update(token_client, organizer, event, item, category, item2, cate
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/'.format(organizer.slug, event.slug, item.pk),
@@ -1165,8 +1236,8 @@ def test_item_update(token_client, organizer, event, item, category, item2, cate
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
item.personalized = True
item.admission = True
@@ -1322,8 +1393,8 @@ def test_item_update_with_variation(token_client, organizer, event, item):
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
@pytest.mark.django_db
@@ -1345,8 +1416,8 @@ def test_item_update_with_addon(token_client, organizer, event, item, category):
format='json'
)
assert resp.status_code == 400
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use ' \
'the dedicated nested endpoint."]}'
assert resp.content.decode() == '{"non_field_errors":["Updating add-ons, bundles, program times or variations via ' \
'PATCH/PUT is not supported. Please use the dedicated nested endpoint."]}'
@pytest.mark.django_db
@@ -1881,6 +1952,123 @@ def test_addons_delete(token_client, organizer, event, item, addon):
assert not item.addons.filter(pk=addon.id).exists()
@pytest.fixture
def program_time(item, category):
return item.program_times.create(start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc))
@pytest.fixture
def program_time2(item, category):
return item.program_times.create(start=datetime(2017, 12, 29, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 30, 0, 0, 0, tzinfo=timezone.utc))
TEST_PROGRAM_TIMES_RES = {
0: {
"start": "2017-12-27T00:00:00Z",
"end": "2017-12-28T00:00:00Z",
},
1: {
"start": "2017-12-29T00:00:00Z",
"end": "2017-12-30T00:00:00Z",
}
}
@pytest.mark.django_db
def test_program_times_list(token_client, organizer, event, item, program_time, program_time2):
res = dict(TEST_PROGRAM_TIMES_RES)
res[0]["id"] = program_time.pk
res[1]["id"] = program_time2.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[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']
@pytest.mark.django_db
def test_program_times_detail(token_client, organizer, event, item, program_time):
res = dict(TEST_PROGRAM_TIMES_RES)
res[0]["id"] = program_time.pk
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug,
item.pk, program_time.pk))
assert resp.status_code == 200
assert res[0] == resp.data
@pytest.mark.django_db
def test_program_times_create(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
with scopes_disabled():
program_time = ItemProgramTime.objects.get(pk=resp.data['id'])
assert datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc) == program_time.start
assert datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc) == program_time.end
resp = token_client.post(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/'.format(organizer.slug, event.slug, item.pk),
{
"start": "2017-12-28T00:00:00Z",
"end": "2017-12-27T00:00:00Z"
},
format='json'
)
assert resp.status_code == 400
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_update(token_client, organizer, event, item, program_time):
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug, item.pk,
program_time.pk),
{
"start": "2017-12-26T00:00:00Z"
},
format='json'
)
assert resp.status_code == 200
with scopes_disabled():
program_time = ItemProgramTime.objects.get(pk=resp.data['id'])
assert datetime(2017, 12, 26, 0, 0, 0, tzinfo=timezone.utc) == program_time.start
assert datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc) == program_time.end
resp = token_client.patch(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug, item.pk,
program_time.pk),
{
"start": "2017-12-30T00:00:00Z"
},
format='json'
)
assert resp.status_code == 400
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_delete(token_client, organizer, event, item, program_time):
resp = token_client.delete(
'/api/v1/organizers/{}/events/{}/items/{}/program_times/{}/'.format(organizer.slug, event.slug,
item.pk, program_time.pk))
assert resp.status_code == 204
with scopes_disabled():
assert not item.program_times.filter(pk=program_time.id).exists()
@pytest.fixture
def quota(event, item):
q = event.quotas.create(name="Budget Quota", size=200)

View File

@@ -1996,7 +1996,7 @@ def test_pdf_data(token_client, organizer, event, order, django_assert_max_num_q
assert not resp.data['positions'][0].get('pdf_data')
# order list
with django_assert_max_num_queries(32):
with django_assert_max_num_queries(33):
resp = token_client.get('/api/v1/organizers/{}/events/{}/orders/?pdf_data=true'.format(
organizer.slug, event.slug
))

View File

@@ -39,7 +39,9 @@ import pytest
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Event, Organizer, Question, SeatingPlan
from pretix.base.models import (
Event, ItemProgramTime, Organizer, Question, SeatingPlan,
)
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
@@ -78,6 +80,10 @@ def test_full_clone_same_organizer():
# todo: test that item pictures are copied, not linked
ItemMetaValue.objects.create(item=item1, property=item_meta, value="Foo")
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))
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)
item2v = item2.variations.create(value="red", default_price=15, all_sales_channels=False)
@@ -161,6 +167,8 @@ def test_full_clone_same_organizer():
assert copied_item1.category == copied_event.categories.get(name='Tickets')
assert copied_item1.limit_sales_channels.get() == sc
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_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

@@ -681,6 +681,10 @@ class ItemsTest(ItemFormTest):
self.var2.save()
prop = self.event1.item_meta_properties.create(name="Foo")
self.item2.meta_values.create(property=prop, value="Bar")
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))
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])
@@ -709,6 +713,8 @@ class ItemsTest(ItemFormTest):
assert i_new.meta_data == i_old.meta_data == {"Foo": "Bar"}
assert set(i_new.questions.all()) == set(i_old.questions.all())
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
def test_add_to_existing_quota(self):
with scopes_disabled():