From fd9d03786b74128207cfa5a1feb5c4147f89e317 Mon Sep 17 00:00:00 2001 From: Phin Wolkwitz Date: Thu, 6 Nov 2025 12:24:47 +0100 Subject: [PATCH] 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 * 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 * 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 * 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 Co-authored-by: Richard Schreiber --- doc/api/resources/item_program_times.rst | 222 ++++++++++++++++++ doc/api/resources/items.rst | 43 +++- src/pretix/api/serializers/item.py | 55 ++++- src/pretix/api/urls.py | 1 + src/pretix/api/views/item.py | 59 ++++- .../base/migrations/0294_item_program_time.py | 25 ++ src/pretix/base/models/__init__.py | 5 +- src/pretix/base/models/event.py | 7 +- src/pretix/base/models/items.py | 24 ++ src/pretix/base/pdf.py | 17 ++ src/pretix/control/forms/item.py | 51 +++- src/pretix/control/logdisplay.py | 3 + .../item/include_program_times.html | 70 ++++++ src/pretix/control/views/item.py | 39 ++- src/pretix/control/views/pdf.py | 7 +- src/pretix/presale/ical.py | 140 +++++++---- src/tests/api/test_items.py | 206 +++++++++++++++- src/tests/api/test_orders.py | 2 +- src/tests/base/test_event_clone.py | 10 +- src/tests/control/test_items.py | 6 + 20 files changed, 903 insertions(+), 89 deletions(-) create mode 100644 doc/api/resources/item_program_times.rst create mode 100644 src/pretix/base/migrations/0294_item_program_time.py create mode 100644 src/pretix/control/templates/pretixcontrol/item/include_program_times.html diff --git a/doc/api/resources/item_program_times.rst b/doc/api/resources/item_program_times.rst new file mode 100644 index 0000000000..0bfaf617a3 --- /dev/null +++ b/doc/api/resources/item_program_times.rst @@ -0,0 +1,222 @@ +Item program times +================== + +Resource description +-------------------- + +Program times for products (items) that can be set in addition to event times, e.g. to display seperate schedules within an event. +The program times resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +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. +===================================== ========================== ======================================================= + +.. versionchanged:: TODO + + The resource has been added. + + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/ + + Returns a list of all program times for a given item. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/11/program_times/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 3, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "start": "2025-08-14T22:00:00Z", + "end": "2025-08-15T00:00:00Z" + }, + { + "id": 3, + "start": "2025-08-12T22:00:00Z", + "end": "2025-08-13T22:00:00Z" + }, + { + "id": 14, + "start": "2025-08-15T22:00:00Z", + "end": "2025-08-17T22:00:00Z" + } + ] + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event/item does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/ + + Returns information on one program time, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "start": "2025-08-15T22:00:00Z", + "end": "2025-10-27T23:00:00Z" + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param item: The ``id`` field of the item to fetch + :param id: The ``id`` field of the program time to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/ + + Creates a new program time + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "start": "2025-08-15T10:00:00Z", + "end": "2025-08-15T22:00:00Z" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 17, + "start": "2025-08-15T10:00:00Z", + "end": "2025-08-15T22:00:00Z" + } + + :param organizer: The ``slug`` field of the organizer of the event/item to create a program time for + :param event: The ``slug`` field of the event to create a program time for + :param item: The ``id`` field of the item to create a program time for + :statuscode 201: no error + :statuscode 400: The program time could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/items/(item)/program_times/(id)/ + + Update a program time. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + You can change all fields of the resource except the ``id`` field. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "start": "2025-08-14T10:00:00Z" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "start": "2025-08-14T10:00:00Z", + "end": "2025-08-15T12:00:00Z" + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the item to modify + :param id: The ``id`` field of the program time to modify + :statuscode 200: no error + :statuscode 400: The program time could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/items/(id)/program_times/(id)/ + + Delete a program time. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/items/1/program_times/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the item to modify + :param id: The ``id`` field of the program time to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 383da02d7d..4c80412840 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -139,6 +139,9 @@ has_variations boolean Shows whether variations list of objects A list with one object for each variation of this item. Can be empty. Only writable during creation, use separate endpoint to modify this later. +program_times list of objects A list with one object for each program time of this item. + Can be empty. Only writable during creation, + use separate endpoint to modify this later. ├ id integer Internal ID of the variation ├ value multi-lingual string The "name" of the variation ├ default_price money (string) The price set directly for this variation or ``null`` @@ -225,6 +228,10 @@ meta_data object Values set fo The ``hidden_if_item_available_mode`` attributes has been added. +.. versionchanged:: 2025.9 + + The ``program_times`` attribute has been added. + Notes ----- @@ -232,9 +239,9 @@ Please note that an item either always has variations or never has. Once created change to an item without and vice versa. To create an item with variations ensure that you POST an item with at least one variation. -Also note that ``variations``, ``bundles``, and ``addons`` are only supported on ``POST``. To update/delete variations, -bundles, and add-ons please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` -with nested ``variations``, ``bundles`` and/or ``addons``. +Also note that ``variations``, ``bundles``, ``addons`` and ``program_times`` are only supported on ``POST``. To update/delete variations, +bundles, add-ons and program times please use the dedicated nested endpoints. By design this endpoint does not support ``PATCH`` and ``PUT`` +with nested ``variations``, ``bundles``, ``addons`` and/or ``program_times``. Endpoints --------- @@ -373,7 +380,8 @@ Endpoints } ], "addons": [], - "bundles": [] + "bundles": [], + "program_times": [] } ] } @@ -525,7 +533,8 @@ Endpoints } ], "addons": [], - "bundles": [] + "bundles": [], + "program_times": [] } :param organizer: The ``slug`` field of the organizer to fetch @@ -653,7 +662,13 @@ Endpoints } ], "addons": [], - "bundles": [] + "bundles": [], + "program_times": [ + { + "start": "2025-08-14T22:00:00Z", + "end": "2025-08-15T00:00:00Z" + } + ] } **Example response**: @@ -773,7 +788,13 @@ Endpoints } ], "addons": [], - "bundles": [] + "bundles": [], + "program_times": [ + { + "start": "2025-08-14T22:00:00Z", + "end": "2025-08-15T00:00:00Z" + } + ] } :param organizer: The ``slug`` field of the organizer of the event to create an item for @@ -789,8 +810,9 @@ Endpoints the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you want to change. - You can change all fields of the resource except the ``has_variations``, ``variations`` and the ``addon`` field. If - you need to update/delete variations or add-ons please use the nested dedicated endpoints. + You can change all fields of the resource except the ``has_variations``, ``variations``, ``addon`` and the + ``program_times`` field. If you need to update/delete variations, add-ons or program times, please use the nested + dedicated endpoints. **Example request**: @@ -924,7 +946,8 @@ Endpoints } ], "addons": [], - "bundles": [] + "bundles": [], + "program_times": [] } :param organizer: The ``slug`` field of the organizer to modify diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 3b1dd9d9ba..dea711d792 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -47,8 +47,9 @@ from pretix.api.serializers.event import MetaDataField from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( - Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation, - ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel, + Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemProgramTime, + ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota, + SalesChannel, ) @@ -187,6 +188,12 @@ class InlineItemAddOnSerializer(serializers.ModelSerializer): 'position', 'price_included', 'multi_allowed') +class InlineItemProgramTimeSerializer(serializers.ModelSerializer): + class Meta: + model = ItemProgramTime + fields = ('start', 'end') + + class ItemBundleSerializer(serializers.ModelSerializer): class Meta: model = ItemBundle @@ -212,6 +219,31 @@ class ItemBundleSerializer(serializers.ModelSerializer): return data +class ItemProgramTimeSerializer(serializers.ModelSerializer): + class Meta: + model = ItemProgramTime + fields = ('id', 'start', 'end') + + def validate(self, data): + data = super().validate(data) + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + start = full_data.get('start') + if not start: + raise ValidationError(_("The program start must not be empty.")) + + end = full_data.get('end') + if not end: + raise ValidationError(_("The program end must not be empty.")) + + if start > end: + raise ValidationError(_("The program end must not be before the program start.")) + + return data + + class ItemAddOnSerializer(serializers.ModelSerializer): class Meta: model = ItemAddOn @@ -250,6 +282,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): addons = InlineItemAddOnSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False) + program_times = InlineItemProgramTimeSerializer(many=True, required=False) tax_rate = ItemTaxRateField(source='*', read_only=True) meta_data = MetaDataField(required=False, source='*') picture = UploadedFileField(required=False, allow_null=True, allowed_types=( @@ -271,7 +304,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): 'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'min_per_order', 'max_per_order', 'checkin_attention', 'checkin_text', 'has_variations', 'variations', - 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', + 'addons', 'bundles', 'program_times', 'original_price', 'require_approval', 'generate_tickets', 'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'hidden_if_item_available_mode', 'allow_waitinglist', 'issue_giftcard', 'meta_data', 'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type', @@ -294,9 +327,9 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): def validate(self, data): data = super().validate(data) - if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data): - raise ValidationError(_('Updating add-ons, bundles, or variations via PATCH/PUT is not supported. Please use the ' - 'dedicated nested endpoint.')) + if self.instance and ('addons' in data or 'variations' in data or 'bundles' in data or 'program_times' in data): + raise ValidationError(_('Updating add-ons, bundles, program times or variations via PATCH/PUT is not ' + 'supported. Please use the dedicated nested endpoint.')) Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) Item.clean_available(data.get('available_from'), data.get('available_until')) @@ -347,6 +380,13 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0)) return value + def validate_program_times(self, value): + if not self.instance: + for program_time_data in value: + ItemProgramTime.clean_start_end(self, start=program_time_data.get('start', None), + end=program_time_data.get('end', None)) + return value + @cached_property def item_meta_properties(self): return { @@ -364,6 +404,7 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): variations_data = validated_data.pop('variations') if 'variations' in validated_data else {} addons_data = validated_data.pop('addons') if 'addons' in validated_data else {} bundles_data = validated_data.pop('bundles') if 'bundles' in validated_data else {} + program_times_data = validated_data.pop('program_times') if 'program_times' in validated_data else {} meta_data = validated_data.pop('meta_data', None) picture = validated_data.pop('picture', None) require_membership_types = validated_data.pop('require_membership_types', []) @@ -398,6 +439,8 @@ class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): ItemAddOn.objects.create(base_item=item, **addon_data) for bundle_data in bundles_data: ItemBundle.objects.create(base_item=item, **bundle_data) + for program_time_data in program_times_data: + ItemProgramTime.objects.create(item=item, **program_time_data) # Meta data if meta_data is not None: diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 71b7a1e944..a4db75dfdd 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -112,6 +112,7 @@ item_router = routers.DefaultRouter() item_router.register(r'variations', item.ItemVariationViewSet) item_router.register(r'addons', item.ItemAddOnViewSet) item_router.register(r'bundles', item.ItemBundleViewSet) +item_router.register(r'program_times', item.ItemProgramTimeViewSet) order_router = routers.DefaultRouter() order_router.register(r'payments', order.PaymentViewSet) diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 19661c60ec..6e0336c0c0 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -46,13 +46,13 @@ from rest_framework.response import Response from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.item import ( ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer, - ItemSerializer, ItemVariationSerializer, QuestionOptionSerializer, - QuestionSerializer, QuotaSerializer, + ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer, + QuestionOptionSerializer, QuestionSerializer, QuotaSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( - CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, - Question, QuestionOption, Quota, + CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime, + ItemVariation, Question, QuestionOption, Quota, ) from pretix.base.services.quotas import QuotaAvailability from pretix.helpers.dicts import merge_dicts @@ -279,6 +279,57 @@ class ItemBundleViewSet(viewsets.ModelViewSet): ) +class ItemProgramTimeViewSet(viewsets.ModelViewSet): + serializer_class = ItemProgramTimeSerializer + queryset = ItemProgramTime.objects.none() + filter_backends = (DjangoFilterBackend, TotalOrderingFilter,) + ordering_fields = ('id',) + ordering = ('id',) + permission = None + write_permission = 'can_change_items' + + @cached_property + def item(self): + return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + + def get_queryset(self): + return self.item.program_times.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + ctx['item'] = self.item + return ctx + + def perform_create(self, serializer): + item = get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event) + serializer.save(item=item) + item.log_action( + 'pretix.event.item.program_times.added', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) + ) + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.item.log_action( + 'pretix.event.item.program_times.changed', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) + ) + + def perform_destroy(self, instance): + super().perform_destroy(instance) + instance.item.log_action( + 'pretix.event.item.program_times.removed', + user=self.request.user, + auth=self.request.auth, + data={'start': instance.start, 'end': instance.end} + ) + + class ItemAddOnViewSet(viewsets.ModelViewSet): serializer_class = ItemAddOnSerializer queryset = ItemAddOn.objects.none() diff --git a/src/pretix/base/migrations/0294_item_program_time.py b/src/pretix/base/migrations/0294_item_program_time.py new file mode 100644 index 0000000000..4a53b9f0f7 --- /dev/null +++ b/src/pretix/base/migrations/0294_item_program_time.py @@ -0,0 +1,25 @@ +# Generated by Django 4.2.19 on 2025-08-11 10:25 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0293_cartposition_price_includes_rounding_correction_and_more'), + ] + + operations = [ + migrations.CreateModel( + name='ItemProgramTime', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('start', models.DateTimeField()), + ('end', models.DateTimeField()), + ('item', + models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='program_times', + to='pretixbase.item')), + ], + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 944737501f..372fc8287c 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -36,8 +36,9 @@ from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue, - ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota, - SubEventItem, SubEventItemVariation, itempicture_upload_to, + ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question, + QuestionOption, Quota, SubEventItem, SubEventItemVariation, + itempicture_upload_to, ) from .log import LogEntry from .media import ReusableMedium diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index c96ee870d7..a6d78ed49e 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -847,7 +847,7 @@ class Event(EventMixin, LoggedModel): from ..signals import event_copy_data from . import ( Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, - ItemVariationMetaValue, Question, Quota, + ItemProgramTime, ItemVariationMetaValue, Question, Quota, ) # Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin. @@ -990,6 +990,11 @@ class Event(EventMixin, LoggedModel): ia.bundled_variation = variation_map[ia.bundled_variation.pk] ia.save(force_insert=True) + for ipt in ItemProgramTime.objects.filter(item__event=other).prefetch_related('item'): + ipt.pk = None + ipt.item = item_map[ipt.item.pk] + ipt.save(force_insert=True) + quota_map = {} for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): quota_map[q.pk] = q diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 7e42f2cb71..0af8c7016b 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -2294,3 +2294,27 @@ class ItemVariationMetaValue(LoggedModel): class Meta: unique_together = ('variation', 'property') + + +class ItemProgramTime(models.Model): + """ + This model can be used to add a program time to an item. + + :param item: The item the program time applies to + :type item: Item + :param start: The date and time this program time starts + :type start: datetime + :param end: The date and time this program time ends + :type end: datetime + """ + item = models.ForeignKey('Item', related_name='program_times', on_delete=models.CASCADE) + start = models.DateTimeField(verbose_name=_("Start")) + end = models.DateTimeField(verbose_name=_("End")) + + def clean(self): + self.clean_start_end(start=self.start, end=self.end) + super().clean() + + def clean_start_end(self, start: datetime = None, end: datetime = None): + if start and end and start > end: + raise ValidationError(_("The program end must not be before the program start.")) diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index ed00d92319..7117551ff8 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -84,6 +84,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import layout_image_variables, layout_text_variables from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.phone_format import phone_format +from pretix.helpers.daterange import datetimerange from pretix.helpers.reportlab import ThumbnailingImageReader, reshaper from pretix.presale.style import get_fonts @@ -490,6 +491,12 @@ DEFAULT_VARIABLES = OrderedDict(( "TIME_FORMAT" ) if op.valid_until else "" }), + ("program_times", { + "label": _("Program times: date and time"), + "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"), + "evaluate": lambda op, order, ev: get_program_times(op, ev) + }), ("medium_identifier", { "label": _("Reusable Medium ID"), "editor_sample": "ABC1234DEF4567", @@ -734,6 +741,16 @@ def get_seat(op: OrderPosition): return None +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() + ]) + + def generate_compressed_addon_list(op, order, event): itemcount = defaultdict(int) addons = [p for p in ( diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 5d0e5140a6..6d5b8f1797 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -56,7 +56,8 @@ from i18nfield.forms import I18nFormField, I18nTextarea from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm from pretix.base.forms.widgets import DatePickerWidget from pretix.base.models import ( - Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, + Item, ItemCategory, ItemProgramTime, ItemVariation, Question, + QuestionOption, Quota, ) from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.signals import item_copy_data @@ -572,6 +573,8 @@ class ItemCreateForm(I18nModelForm): for b in self.cleaned_data['copy_from'].bundles.all(): 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) item_copy_data.send(sender=self.event, source=self.cleaned_data['copy_from'], target=instance) @@ -1321,3 +1324,49 @@ class ItemMetaValueForm(forms.ModelForm): widgets = { 'value': forms.TextInput() } + + +class ItemProgramTimeFormSet(I18nFormSet): + template = "pretixcontrol/item/include_program_times.html" + title = _('Program times') + + def _construct_form(self, i, **kwargs): + kwargs['event'] = self.event + return super()._construct_form(i, **kwargs) + + @property + def empty_form(self): + self.is_valid() + form = self.form( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + use_required_attribute=False, + locales=self.locales, + event=self.event + ) + self.add_fields(form, None) + return form + + +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) + + class Meta: + model = ItemProgramTime + localized_fields = '__all__' + fields = [ + 'start', + 'end', + ] + field_classes = { + 'start': forms.SplitDateTimeField, + 'end': forms.SplitDateTimeField, + } + widgets = { + 'start': SplitDateTimePickerWidget(), + 'end': SplitDateTimePickerWidget(), + } diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 552d075183..48fdfa8ce8 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -882,6 +882,9 @@ class EventPluginStateLogEntryType(EventLogEntryType): 'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'), 'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'), 'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'), + 'pretix.event.item.program_times.added': _('A program time has been added to this product.'), + 'pretix.event.item.program_times.changed': _('A program time has been changed on this product.'), + 'pretix.event.item.program_times.removed': _('A program time has been removed from this product.'), }) class CoreItemLogEntryType(ItemLogEntryType): pass diff --git a/src/pretix/control/templates/pretixcontrol/item/include_program_times.html b/src/pretix/control/templates/pretixcontrol/item/include_program_times.html new file mode 100644 index 0000000000..e223a0eecb --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/item/include_program_times.html @@ -0,0 +1,70 @@ +{% load i18n %} +{% load bootstrap3 %} +{% load formset_tags %} +

+ {% blocktrans trimmed %} + With program times, you can set specific dates and times for this product. + This is useful if this product represents access to parts of your event that happen at different times than your event in general. + This will not affect access control, but will affect calendar invites and ticket output. + {% endblocktrans %} +

+ +
+ {{ formset.management_form }} + {% bootstrap_formset_errors formset %} +
+ {% for form in formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
+
+
+
+

{% trans "Program time" %}

+
+
+ +
+
+
+
+ {% bootstrap_form_errors form %} + {% bootstrap_field form.start layout="control" %} + {% bootstrap_field form.end layout="control" %} +
+
+ {% endfor %} +
+ +

+ +

+
diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index f120a03d5c..b0af1dda13 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -60,13 +60,14 @@ from django.views.generic.detail import DetailView, SingleObjectMixin from django_countries.fields import Country from pretix.api.serializers.item import ( - ItemAddOnSerializer, ItemBundleSerializer, ItemVariationSerializer, + ItemAddOnSerializer, ItemBundleSerializer, ItemProgramTimeSerializer, + ItemVariationSerializer, ) from pretix.base.forms import I18nFormSet from pretix.base.models import ( - CartPosition, Item, ItemCategory, ItemVariation, Order, OrderPosition, - Question, QuestionAnswer, QuestionOption, Quota, SeatCategoryMapping, - Voucher, + CartPosition, Item, ItemCategory, ItemProgramTime, ItemVariation, Order, + OrderPosition, Question, QuestionAnswer, QuestionOption, Quota, + SeatCategoryMapping, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue @@ -75,9 +76,9 @@ from pretix.base.services.tickets import invalidate_cache from pretix.base.signals import quota_availability from pretix.control.forms.item import ( CategoryForm, ItemAddOnForm, ItemAddOnsFormSet, ItemBundleForm, - ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemUpdateForm, - ItemVariationForm, ItemVariationsFormSet, QuestionForm, QuestionOptionForm, - QuotaForm, + ItemBundleFormSet, ItemCreateForm, ItemMetaValueForm, ItemProgramTimeForm, + ItemProgramTimeFormSet, ItemUpdateForm, ItemVariationForm, + ItemVariationsFormSet, QuestionForm, QuestionOptionForm, QuotaForm, ) from pretix.control.permissions import ( EventPermissionRequiredMixin, event_permission_required, @@ -1431,7 +1432,8 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE form.instance.position = i setattr(form.instance, attr, self.get_object()) created = not form.instance.pk - form.save() + if form.has_changed(): + form.save() if form.has_changed() and any(a for a in form.changed_data if a != 'ORDER'): change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} if key == 'variations': @@ -1497,6 +1499,16 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE 'bundles', 'bundles', 'base_item', order=False, serializer=ItemBundleSerializer ) + elif k == 'program_times': + self.save_formset( + 'program_times', 'program_times', order=False, + serializer=ItemProgramTimeSerializer + ) + if not change_data: + for f in v.forms: + if (f in v.deleted_forms and f.instance.pk) or f.has_changed(): + invalidate_cache.apply_async(kwargs={'event': self.request.event.pk, 'item': self.object.pk}) + break else: v.save() @@ -1559,9 +1571,20 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE queryset=ItemBundle.objects.filter(base_item=self.get_object()), event=self.request.event, item=self.item, prefix="bundles" )), + ('program_times', inlineformset_factory( + Item, ItemProgramTime, + form=ItemProgramTimeForm, formset=ItemProgramTimeFormSet, + can_order=False, can_delete=True, extra=0 + )( + self.request.POST if self.request.method == "POST" else None, + queryset=ItemProgramTime.objects.filter(item=self.get_object()), + event=self.request.event, prefix="program_times" + )), ]) if not self.object.has_variations: del f['variations'] + if self.item.event.has_subevents: + del f['program_times'] i = 0 for rec, resp in item_formsets.send(sender=self.request.event, item=self.item, request=self.request): diff --git a/src/pretix/control/views/pdf.py b/src/pretix/control/views/pdf.py index 8b863f3720..719fa4cd93 100644 --- a/src/pretix/control/views/pdf.py +++ b/src/pretix/control/views/pdf.py @@ -44,7 +44,9 @@ from pypdf.errors import PdfReadError from reportlab.lib.units import mm from pretix.base.i18n import language -from pretix.base.models import CachedFile, InvoiceAddress, OrderPosition +from pretix.base.models import ( + CachedFile, InvoiceAddress, ItemProgramTime, OrderPosition, +) from pretix.base.pdf import get_images, get_variables from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.control.permissions import EventPermissionRequiredMixin @@ -95,6 +97,9 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView): description=_("Sample product description")) item2 = self.request.event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40')) + ItemProgramTime.objects.create(start=now(), end=now(), item=item) + ItemProgramTime.objects.create(start=now(), end=now(), item=item2) + from pretix.base.models import Order order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(), email='sample@pretix.eu', diff --git a/src/pretix/presale/ical.py b/src/pretix/presale/ical.py index cb5940a417..14a1ec9ea8 100644 --- a/src/pretix/presale/ical.py +++ b/src/pretix/presale/ical.py @@ -20,10 +20,12 @@ # . # import datetime +from collections import namedtuple from urllib.parse import urlparse import vobject from django.conf import settings +from django.db.models import prefetch_related_objects from django.utils.formats import date_format from django.utils.translation import gettext as _ @@ -122,61 +124,109 @@ def get_private_icals(event, positions): creation_time = datetime.datetime.now(datetime.timezone.utc) calobjects = [] + calentries = set() # using set for automatic deduplication of CalEntries + CalEntry = namedtuple('CalEntry', ['summary', 'description', 'location', 'dtstart', 'dtend', 'uid']) - evs = set(p.subevent or event for p in positions) - for ev in evs: - if isinstance(ev, Event): + # collecting the positions' calendar entries, preferring the most exact date and time available (positions > subevent > event) + prefetch_related_objects(positions, 'item__program_times') + for p in positions: + ev = p.subevent or event + program_times = p.item.program_times.all() + if program_times: + # if program times have been configured, they are preferred for the position's calendar entries url = build_absolute_uri(event, 'presale:event.index') + for index, pt in enumerate(program_times): + summary = _('{event} - {item}').format(event=ev, item=p.item.name) + if event.settings.mail_attach_ical_description: + ctx = get_email_context(event=event, event_or_subevent=ev) + description = format_map(str(event.settings.mail_attach_ical_description), ctx) + else: + # Default description + descr = [] + descr.append(_('Tickets: {url}').format(url=url)) + descr.append(str(_('Start: {datetime}')).format( + datetime=date_format(pt.start.astimezone(tz), 'SHORT_DATETIME_FORMAT') + )) + descr.append(str(_('End: {datetime}')).format( + datetime=date_format(pt.end.astimezone(tz), 'SHORT_DATETIME_FORMAT') + )) + # 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 + dtstart = pt.start.astimezone(tz) + dtend = pt.end.astimezone(tz) + uid = 'pretix-{}-{}-{}-{}@{}'.format( + event.organizer.slug, + event.slug, + p.item.id, + index, + urlparse(url).netloc + ) + calentries.add(CalEntry(summary, description, location, dtstart, dtend, uid)) else: - url = build_absolute_uri(event, 'presale:event.index', { - 'subevent': ev.pk - }) + # without program times, the subevent or event times are used for calendar entries, preferring subevents + if p.subevent: + url = build_absolute_uri(event, 'presale:event.index', { + 'subevent': p.subevent.pk + }) + else: + url = build_absolute_uri(event, 'presale:event.index') - if event.settings.mail_attach_ical_description: - ctx = get_email_context(event=event, event_or_subevent=ev) - description = format_map(str(event.settings.mail_attach_ical_description), ctx) - else: - # Default description - descr = [] - descr.append(_('Tickets: {url}').format(url=url)) - if ev.date_admission: - descr.append(str(_('Admission: {datetime}')).format( - datetime=date_format(ev.date_admission.astimezone(tz), 'SHORT_DATETIME_FORMAT') - )) + if event.settings.mail_attach_ical_description: + ctx = get_email_context(event=event, event_or_subevent=ev) + description = format_map(str(event.settings.mail_attach_ical_description), ctx) + else: + # Default description + descr = [] + descr.append(_('Tickets: {url}').format(url=url)) + if ev.date_admission: + descr.append(str(_('Admission: {datetime}')).format( + datetime=date_format(ev.date_admission.astimezone(tz), 'SHORT_DATETIME_FORMAT') + )) - # 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) + # 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) + summary = str(ev.name) + if ev.location: + location = ", ".join(l.strip() for l in str(ev.location).splitlines() if l.strip()) + else: + location = None + if event.settings.show_times: + dtstart = ev.date_from.astimezone(tz) + else: + dtstart = ev.date_from.astimezone(tz).date() + if event.settings.show_date_to and ev.date_to: + if event.settings.show_times: + dtend = ev.date_to.astimezone(tz) + else: + # with full-day events date_to in pretix is included (e.g. last day) + # whereas dtend in vcalendar is non-inclusive => add one day for export + dtend = ev.date_to.astimezone(tz).date() + datetime.timedelta(days=1) + else: + dtend = None + uid = 'pretix-{}-{}-{}@{}'.format( + event.organizer.slug, + event.slug, + ev.pk if p.subevent else '0', + urlparse(url).netloc + ) + calentries.add(CalEntry(summary, description, location, dtstart, dtend, uid)) + for calentry in calentries: cal = vobject.iCalendar() cal.add('prodid').value = '-//pretix//{}//'.format(settings.PRETIX_INSTANCE_NAME.replace(" ", "_")) vevent = cal.add('vevent') - vevent.add('summary').value = str(ev.name) - vevent.add('description').value = description + vevent.add('summary').value = calentry.summary + vevent.add('description').value = calentry.description vevent.add('dtstamp').value = creation_time - if ev.location: - vevent.add('location').value = ", ".join(l.strip() for l in str(ev.location).splitlines() if l.strip()) - - vevent.add('uid').value = 'pretix-{}-{}-{}@{}'.format( - event.organizer.slug, - event.slug, - ev.pk if not isinstance(ev, Event) else '0', - urlparse(url).netloc - ) - - if event.settings.show_times: - vevent.add('dtstart').value = ev.date_from.astimezone(tz) - else: - vevent.add('dtstart').value = ev.date_from.astimezone(tz).date() - - if event.settings.show_date_to and ev.date_to: - if event.settings.show_times: - vevent.add('dtend').value = ev.date_to.astimezone(tz) - else: - # with full-day events date_to in pretix is included (e.g. last day) - # whereas dtend in vcalendar is non-inclusive => add one day for export - vevent.add('dtend').value = ev.date_to.astimezone(tz).date() + datetime.timedelta(days=1) - + if calentry.location: + vevent.add('location').value = calentry.location + vevent.add('uid').value = calentry.uid + vevent.add('dtstart').value = calentry.dtstart + if calentry.dtend: + vevent.add('dtend').value = calentry.dtend calobjects.append(cal) return calobjects diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 4b10b80bfe..3353cd5ede 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -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) diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index 888b0aefef..6f41ac82b5 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -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 )) diff --git a/src/tests/base/test_event_clone.py b/src/tests/base/test_event_clone.py index c01339be72..784bd079a4 100644 --- a/src/tests/base/test_event_clone.py +++ b/src/tests/base/test_event_clone.py @@ -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 diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index ef938206d5..b3b4c00578 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -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():