diff --git a/doc/api/resources/item_program_times.rst b/doc/api/resources/item_program_times.rst new file mode 100644 index 000000000..0bfaf617a --- /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 383da02d7..4c8041284 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 3b1dd9d9b..dea711d79 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 71b7a1e94..a4db75dfd 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 19661c60e..6e0336c0c 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 000000000..4a53b9f0f --- /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 944737501..372fc8287 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 c96ee870d..a6d78ed49 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 7e42f2cb7..0af8c7016 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 ed00d9231..7117551ff 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 5d0e5140a..6d5b8f179 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 552d07518..48fdfa8ce 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 000000000..e223a0eec --- /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 %} +
+ ++ +
+