diff --git a/doc/api/resources/carts.rst b/doc/api/resources/carts.rst index fdf36b0fea..fb3bcdb8b8 100644 --- a/doc/api/resources/carts.rst +++ b/doc/api/resources/carts.rst @@ -36,12 +36,20 @@ answers list of objects Answers to user ├ question_identifier string The question's ``identifier`` field ├ options list of integers Internal IDs of selected option(s)s (only for choice types) └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s +seat objects The assigned seat. Can be ``null``. +├ id integer Internal ID of the seat instance +├ name string Human-readable seat name +└ seat_guid string Identifier of the seat within the seating plan ===================================== ========================== ======================================================= .. versionchanged:: 1.17 This resource has been added. +.. versionchanged:: 3.0 + + This ``seat`` attribute has been added. + Cart position endpoints ----------------------- @@ -87,6 +95,7 @@ Cart position endpoints "datetime": "2018-06-11T10:00:00Z", "expires": "2018-06-11T10:00:00Z", "includes_tax": true, + "seat": null, "answers": [] } ] @@ -132,6 +141,7 @@ Cart position endpoints "datetime": "2018-06-11T10:00:00Z", "expires": "2018-06-11T10:00:00Z", "includes_tax": true, + "seat": null, "answers": [] } @@ -178,6 +188,7 @@ Cart position endpoints * ``item`` * ``variation`` (optional) * ``price`` + * ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.) * ``attendee_name`` **or** ``attendee_name_parts`` (optional) * ``attendee_email`` (optional) * ``subevent`` (optional) diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst index b5d9157c4b..6bbd6d7227 100644 --- a/doc/api/resources/checkinlists.rst +++ b/doc/api/resources/checkinlists.rst @@ -396,6 +396,7 @@ Order position endpoints "addon_to": null, "subevent": null, "pseudonymization_id": "MQLJvANO3B", + "seat": null, "checkins": [ { "list": 1, @@ -505,6 +506,7 @@ Order position endpoints "addon_to": null, "subevent": null, "pseudonymization_id": "MQLJvANO3B", + "seat": null, "checkins": [ { "list": 1, diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index 216aa865d8..2c40b1b834 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -27,9 +27,13 @@ presale_end datetime The date at whi location multi-lingual string The event location (or ``null``) has_subevents boolean ``true`` if the event series feature is active for this event. Cannot change after event is created. -meta_data dict Values set for organizer-specific meta data parameters. +meta_data object Values set for organizer-specific meta data parameters. plugins list A list of package names of the enabled plugins for this event. +seating_plan integer If reserved seating is in use, the ID of a seating + plan. Otherwise ``null``. +seat_category_mapping object An object mapping categories of the seating plan + (strings) to items in the event (integers or ``null``). ===================================== ========================== ======================================================= @@ -54,6 +58,10 @@ plugins list A list of packa When cloning events, the ``testmode`` attribute will now be cloned, too. +.. versionchanged:: 3.0 + + The attributes ``seating_plan`` and ``seat_category_mapping`` have been added. + Endpoints --------- @@ -99,6 +107,8 @@ Endpoints "location": null, "has_subevents": false, "meta_data": {}, + "seating_plan": null, + "seat_category_mapping": {}, "plugins": [ "pretix.plugins.banktransfer" "pretix.plugins.stripe" @@ -160,6 +170,8 @@ Endpoints "presale_end": null, "location": null, "has_subevents": false, + "seating_plan": null, + "seat_category_mapping": {}, "meta_data": {}, "plugins": [ "pretix.plugins.banktransfer" @@ -205,6 +217,8 @@ Endpoints "is_public": false, "presale_start": null, "presale_end": null, + "seating_plan": null, + "seat_category_mapping": {}, "location": null, "has_subevents": false, "meta_data": {}, @@ -235,6 +249,8 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "seating_plan": null, + "seat_category_mapping": {}, "has_subevents": false, "meta_data": {}, "plugins": [ @@ -284,6 +300,8 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "seating_plan": null, + "seat_category_mapping": {}, "has_subevents": false, "meta_data": {}, "plugins": [ @@ -314,6 +332,8 @@ Endpoints "presale_end": null, "location": null, "has_subevents": false, + "seating_plan": null, + "seat_category_mapping": {}, "meta_data": {}, "plugins": [ "pretix.plugins.stripe", @@ -375,6 +395,8 @@ Endpoints "presale_end": null, "location": null, "has_subevents": false, + "seating_plan": null, + "seat_category_mapping": {}, "meta_data": {}, "plugins": [ "pretix.plugins.banktransfer", diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 5b25708d0f..0173f0cdfe 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -23,4 +23,5 @@ Resources and endpoints waitinglist carts webhooks + seatingplans billing_invoices diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 4c6a71bab9..0942e56bf0 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -176,6 +176,10 @@ answers list of objects Answers to user ├ question_identifier string The question's ``identifier`` field ├ options list of integers Internal IDs of selected option(s)s (only for choice types) └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s +seat objects The assigned seat. Can be ``null``. +├ id integer Internal ID of the seat instance +├ name string Human-readable seat name +└ seat_guid string Identifier of the seat within the seating plan pdf_data object Data object required for ticket PDF generation. By default, this field is missing. It will be added only if you add the ``pdf_data=true`` query parameter to your request. @@ -197,6 +201,10 @@ pdf_data object Data object req The attributes ``pseudonymization_id`` and ``pdf_data`` have been added. +.. versionchanged:: 3.0 + + The attribute ``seat`` has been added. + .. _order-payment-resource: Order payment resource @@ -328,6 +336,7 @@ List of all orders "addon_to": null, "subevent": null, "pseudonymization_id": "MQLJvANO3B", + "seat": null, "checkins": [ { "list": 44, @@ -470,6 +479,7 @@ Fetching individual orders "addon_to": null, "subevent": null, "pseudonymization_id": "MQLJvANO3B", + "seat": null, "checkins": [ { "list": 44, @@ -737,7 +747,7 @@ Creating orders then call the ``mark_paid`` API method. * ``testmode`` (optional) – Defaults to ``false`` * ``consume_carts`` (optional) – A list of cart IDs. All cart positions with these IDs will be deleted if the - order creation is successful. Any quotas that become free by this operation will be credited to your order + order creation is successful. Any quotas or seats that become free by this operation will be credited to your order creation. * ``email`` * ``locale`` @@ -771,6 +781,7 @@ Creating orders * ``item`` * ``variation`` * ``price`` + * ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.) * ``attendee_name`` **or** ``attendee_name_parts`` * ``attendee_email`` * ``secret`` (optional) @@ -1287,6 +1298,7 @@ List of all order positions "tax_value": "0.00", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "pseudonymization_id": "MQLJvANO3B", + "seat": null, "addon_to": null, "subevent": null, "checkins": [ @@ -1389,6 +1401,7 @@ Fetching individual positions "addon_to": null, "subevent": null, "pseudonymization_id": "MQLJvANO3B", + "seat": null, "checkins": [ { "list": 44, diff --git a/doc/api/resources/seatingplans.rst b/doc/api/resources/seatingplans.rst new file mode 100644 index 0000000000..e1c46ddd53 --- /dev/null +++ b/doc/api/resources/seatingplans.rst @@ -0,0 +1,209 @@ +.. _`rest-seatingplans`: + +Seating plans +============= + +Resource description +-------------------- + +The seating plan resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +id integer Internal ID of the plan +name string Human-readable name of the plan +layout object JSON representation of the seating plan. These + representations follow a JSON schema that currently + still evolves. The version in use can be found `here`_. +===================================== ========================== ======================================================= + +.. versionchanged:: 3.0 + + This endpoint has been added. + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/seatingplans/ + + Returns a list of all seating plans within a given organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/seatingplans/ 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": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 2, + "name": "Main plan", + "layout": { … } + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/seatingplans/(id)/ + + Returns information on one plan, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/seatingplans/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": 2, + "name": "Main plan", + "layout": { … } + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param id: The ``id`` field of the seating plan to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/seatingplans/ + + Creates a new seating plan + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "name": "Main plan", + "layout": { … } + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "id": 3, + "name": "Main plan", + "layout": { … } + } + + :param organizer: The ``slug`` field of the organizer to create a seating plan for + :statuscode 201: no error + :statuscode 400: The seating plan could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/seatingplans/(id)/ + + Update a plan. 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. **You can not change a plan while it is in use for + any events.** + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "name": "Old plan" + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": "Old plan", + "layout": { … } + } + + :param organizer: The ``slug`` field of the organizer to modify + :param id: The ``id`` field of the plan to modify + :statuscode 200: no error + :statuscode 400: The plan could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource **or** the plan is currently in use. + +.. http:delete:: /api/v1/organizers/(organizer)/seatingplans/(id)/ + + Delete a plan. You can not delete plans which are currently in use by any events. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/seatingplans/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 id: The ``id`` field of the plan to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the plan is currently in use. + + +.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/seating/seating-plan.schema.json diff --git a/doc/api/resources/subevents.rst b/doc/api/resources/subevents.rst index ab658ab4a5..fe14237964 100644 --- a/doc/api/resources/subevents.rst +++ b/doc/api/resources/subevents.rst @@ -36,7 +36,11 @@ variation_price_overrides list of objects List of variati the default price ├ variation integer The internal variation ID └ price money (string) The price or ``null`` for the default price -meta_data dict Values set for organizer-specific meta data parameters. +meta_data object Values set for organizer-specific meta data parameters. +seating_plan integer If reserved seating is in use, the ID of a seating + plan. Otherwise ``null``. +seat_category_mapping object An object mapping categories of the seating plan + (strings) to items in the event (integers or ``null``). ===================================== ========================== ======================================================= .. versionchanged:: 1.7 @@ -54,6 +58,10 @@ meta_data dict Values set for The attribute ``is_public`` has been added. +.. versionchanged:: 3.0 + + The attributes ``seating_plan`` and ``seat_category_mapping`` have been added. + Endpoints --------- @@ -93,6 +101,8 @@ Endpoints "date_admission": null, "presale_start": null, "presale_end": null, + "seating_plan": null, + "seat_category_mapping": {}, "location": null, "item_price_overrides": [ { @@ -142,6 +152,8 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "seating_plan": null, + "seat_category_mapping": {}, "item_price_overrides": [ { "item": 2, @@ -172,6 +184,8 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "seating_plan": null, + "seat_category_mapping": {}, "item_price_overrides": [ { "item": 2, @@ -223,6 +237,8 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "seating_plan": null, + "seat_category_mapping": {}, "item_price_overrides": [ { "item": 2, @@ -287,6 +303,8 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "seating_plan": null, + "seat_category_mapping": {}, "item_price_overrides": [ { "item": 2, @@ -371,6 +389,8 @@ Endpoints "presale_start": null, "presale_end": null, "location": null, + "seating_plan": null, + "seat_category_mapping": {}, "item_price_overrides": [ { "item": 2, diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index fb76050c3b..2ca07c7305 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -26,7 +26,11 @@ Frontend -------- .. automodule:: pretix.presale.signals - :members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, checkout_flow_steps, order_info, order_meta_from_request, position_info + :members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info + + +.. automodule:: pretix.presale.signals + :members: order_info, order_meta_from_request Request flow """""""""""" @@ -45,7 +49,7 @@ Backend .. automodule:: pretix.control.signals :members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, - order_info, event_settings_widget, oauth_application_registered, order_position_buttons, nav_item + order_info, event_settings_widget, oauth_application_registered, order_position_buttons, nav_item, subevent_forms .. automodule:: pretix.base.signals diff --git a/doc/spelling_wordlist.txt b/doc/spelling_wordlist.txt index 13dca1cc66..0df17f0a7c 100644 --- a/doc/spelling_wordlist.txt +++ b/doc/spelling_wordlist.txt @@ -40,6 +40,7 @@ frontend frontpage gettext gunicorn +guid hardcoded hostname idempotency diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py index 160779ba21..8e864b872f 100644 --- a/src/pretix/api/serializers/cart.py +++ b/src/pretix/api/serializers/cart.py @@ -8,31 +8,33 @@ from rest_framework.exceptions import ValidationError from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import ( - AnswerCreateSerializer, AnswerSerializer, + AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer, ) -from pretix.base.models import Quota +from pretix.base.models import Quota, Seat from pretix.base.models.orders import CartPosition class CartPositionSerializer(I18nAwareModelSerializer): answers = AnswerSerializer(many=True) + seat = InlineSeatSerializer() class Meta: model = CartPosition fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax', - 'answers',) + 'answers', 'seat') class CartPositionCreateSerializer(I18nAwareModelSerializer): answers = AnswerCreateSerializer(many=True, required=False) expires = serializers.DateTimeField(required=False) attendee_name = serializers.CharField(required=False, allow_null=True) + seat = serializers.CharField(required=False, allow_null=True) class Meta: model = CartPosition fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', - 'subevent', 'expires', 'includes_tax', 'answers',) + 'subevent', 'expires', 'includes_tax', 'answers', 'seat') def create(self, validated_data): answers_data = validated_data.pop('answers') @@ -71,6 +73,22 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer): validated_data['attendee_name_parts'] = { '_legacy': attendee_name } + + seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists() + if validated_data.get('seat'): + if not seated: + raise ValidationError('The specified product does not allow to choose a seat.') + try: + seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent')) + except Seat.DoesNotExist: + raise ValidationError('The specified seat does not exist.') + else: + validated_data['seat'] = seat + if not seat.is_available(): + raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)) + elif seated: + raise ValidationError('The specified product requires to choose a seat.') + cp = CartPosition.objects.create(event=self.context['event'], **validated_data) for answ_data in answers_data: diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 11b99374e3..5fa91a3568 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -11,6 +11,9 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import Event, TaxRule from pretix.base.models.event import SubEvent from pretix.base.models.items import SubEventItem, SubEventItemVariation +from pretix.base.services.seating import ( + SeatProtected, generate_seats, validate_plan_change, +) class MetaDataField(Field): @@ -26,6 +29,22 @@ class MetaDataField(Field): } +class SeatCategoryMappingField(Field): + + def to_representation(self, value): + qs = value.seat_category_mappings.all() + if isinstance(value, Event): + qs = qs.filter(subevent=None) + return { + v.layout_category: v.product_id for v in qs + } + + def to_internal_value(self, data): + return { + 'seat_category_mapping': data or {} + } + + class PluginsField(Field): def to_representation(self, obj): @@ -45,12 +64,14 @@ class PluginsField(Field): class EventSerializer(I18nAwareModelSerializer): meta_data = MetaDataField(required=False, source='*') plugins = PluginsField(required=False, source='*') + seat_category_mapping = SeatCategoryMappingField(source='*', required=False) class Meta: model = Event fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', 'date_to', 'date_admission', 'is_public', 'presale_start', - 'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins') + 'presale_end', 'location', 'has_subevents', 'meta_data', 'seating_plan', + 'plugins', 'seat_category_mapping') def validate(self, data): data = super().validate(data) @@ -61,6 +82,9 @@ class EventSerializer(I18nAwareModelSerializer): Event.clean_dates(data.get('date_from'), data.get('date_to')) Event.clean_presale(data.get('presale_start'), data.get('presale_end')) + if full_data.get('has_subevents') and full_data.get('seating_plan'): + raise ValidationError('Event series should not directly be assigned a seating plan.') + return data def validate_has_subevents(self, value): @@ -92,6 +116,27 @@ class EventSerializer(I18nAwareModelSerializer): raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) return value + def validate_seating_plan(self, value): + if value and value.organizer != self.context['request'].organizer: + raise ValidationError('Invalid seating plan.') + if self.instance and self.instance.pk: + try: + validate_plan_change(self.instance, None, value) + except SeatProtected as e: + raise ValidationError(str(e)) + return value + + def validate_seat_category_mapping(self, value): + if value and (not self.instance or not self.instance.pk): + raise ValidationError('You cannot specify seat category mappings on event creation.') + item_cache = {i.pk: i for i in self.instance.items.all()} + result = {} + for k, item in value['seat_category_mapping'].items(): + if item not in item_cache: + raise ValidationError('Item \'{id}\' does not exist.'.format(id=item)) + result[k] = item_cache[item] + return {'seat_category_mapping': result} + def validate_plugins(self, value): from pretix.base.plugins import get_all_plugins @@ -109,6 +154,7 @@ class EventSerializer(I18nAwareModelSerializer): @transaction.atomic def create(self, validated_data): meta_data = validated_data.pop('meta_data', None) + validated_data.pop('seat_category_mapping', None) plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(',')) event = super().create(validated_data) @@ -120,6 +166,10 @@ class EventSerializer(I18nAwareModelSerializer): value=value ) + # Seats + if event.seating_plan: + generate_seats(event, None, event.seating_plan, {}) + # Plugins if plugins is not None: event.set_active_plugins(plugins) @@ -131,6 +181,7 @@ class EventSerializer(I18nAwareModelSerializer): def update(self, instance, validated_data): meta_data = validated_data.pop('meta_data', None) plugins = validated_data.pop('plugins', None) + seat_category_mapping = validated_data.pop('seat_category_mapping', None) event = super().update(instance, validated_data) # Meta data @@ -151,6 +202,29 @@ class EventSerializer(I18nAwareModelSerializer): if prop.name not in meta_data: current_object.delete() + # Seats + if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None): + current_mappings = { + m.layout_category: m + for m in event.seat_category_mappings.filter(subevent=None) + } + if not event.seating_plan: + seat_category_mapping = {} + for key, value in seat_category_mapping.items(): + if key in current_mappings: + m = current_mappings.pop(key) + m.product = value + m.save() + else: + event.seat_category_mappings.create(product=value, layout_category=key) + for m in current_mappings.values(): + m.delete() + if 'seating_plan' in validated_data or seat_category_mapping is not None: + generate_seats(event, None, event.seating_plan, { + m.layout_category: m.product + for m in event.seat_category_mappings.select_related('product').filter(subevent=None) + }) + # Plugins if plugins is not None: event.set_active_plugins(plugins) @@ -196,14 +270,15 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer): class SubEventSerializer(I18nAwareModelSerializer): item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False) variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False) + seat_category_mapping = SeatCategoryMappingField(source='*', required=False) event = SlugRelatedField(slug_field='slug', read_only=True) meta_data = MetaDataField(source='*') class Meta: model = SubEvent fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission', - 'presale_start', 'presale_end', 'location', 'event', 'is_public', - 'item_price_overrides', 'variation_price_overrides', 'meta_data') + 'presale_start', 'presale_end', 'location', 'event', 'is_public', 'seating_plan', + 'item_price_overrides', 'variation_price_overrides', 'meta_data', 'seat_category_mapping') def validate(self, data): data = super().validate(data) @@ -225,6 +300,25 @@ class SubEventSerializer(I18nAwareModelSerializer): def validate_variation_price_overrides(self, data): return list(filter(lambda i: 'variation' in i, data)) + def validate_seating_plan(self, value): + if value and value.organizer != self.context['request'].organizer: + raise ValidationError('Invalid seating plan.') + if self.instance and self.instance.pk: + try: + validate_plan_change(self.context['request'].event, self.instance, value) + except SeatProtected as e: + raise ValidationError(str(e)) + return value + + def validate_seat_category_mapping(self, value): + item_cache = {i.pk: i for i in self.context['request'].event.items.all()} + result = {} + for k, item in value['seat_category_mapping'].items(): + if item not in item_cache: + raise ValidationError('Item \'{id}\' does not exist.'.format(id=item)) + result[k] = item_cache[item] + return {'seat_category_mapping': result} + @cached_property def meta_properties(self): return { @@ -242,6 +336,7 @@ class SubEventSerializer(I18nAwareModelSerializer): item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {} variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {} meta_data = validated_data.pop('meta_data', None) + seat_category_mapping = validated_data.pop('seat_category_mapping', None) subevent = super().create(validated_data) for item_price_override_data in item_price_overrides_data: @@ -257,6 +352,18 @@ class SubEventSerializer(I18nAwareModelSerializer): value=value ) + # Seats + if subevent.seating_plan: + if seat_category_mapping is not None: + for key, value in seat_category_mapping.items(): + self.context['request'].event.seat_category_mappings.create( + product=value, layout_category=key, subevent=subevent + ) + generate_seats(self.context['request'].event, subevent, subevent.seating_plan, { + m.layout_category: m.product + for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent) + }) + return subevent @transaction.atomic @@ -264,6 +371,7 @@ class SubEventSerializer(I18nAwareModelSerializer): item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {} variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {} meta_data = validated_data.pop('meta_data', None) + seat_category_mapping = validated_data.pop('seat_category_mapping', None) subevent = super().update(instance, validated_data) existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)} @@ -300,6 +408,31 @@ class SubEventSerializer(I18nAwareModelSerializer): if prop.name not in meta_data: current_object.delete() + # Seats + if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None): + current_mappings = { + m.layout_category: m + for m in self.context['request'].event.seat_category_mappings.filter(subevent=subevent) + } + if not subevent.seating_plan: + seat_category_mapping = {} + for key, value in seat_category_mapping.items(): + if key in current_mappings: + m = current_mappings.pop(key) + m.product = value + m.save() + else: + self.context['request'].event.seat_category_mappings.create( + product=value, layout_category=key, subevent=subevent + ) + for m in current_mappings.values(): + m.delete() + if 'seating_plan' in validated_data or seat_category_mapping is not None: + generate_seats(self.context['request'].event, subevent, subevent.seating_plan, { + m.layout_category: m.product + for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent) + }) + return subevent diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index e1bd8fd0b1..8ec03af486 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -1,5 +1,4 @@ import json -from collections import Counter from decimal import Decimal from django.utils.timezone import now @@ -15,7 +14,7 @@ from pretix.base.channels import get_all_sales_channels from pretix.base.i18n import language from pretix.base.models import ( Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, - OrderPosition, Question, QuestionAnswer, SubEvent, + OrderPosition, Question, QuestionAnswer, Seat, SubEvent, ) from pretix.base.models.orders import ( CartPosition, OrderFee, OrderPayment, OrderRefund, @@ -71,6 +70,13 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field): return [o.identifier for o in instance.options.all()] +class InlineSeatSerializer(I18nAwareModelSerializer): + + class Meta: + model = Seat + fields = ('id', 'name', 'seat_guid') + + class AnswerSerializer(I18nAwareModelSerializer): question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) @@ -166,12 +172,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer): downloads = PositionDownloadsField(source='*') order = serializers.SlugRelatedField(slug_field='code', read_only=True) pdf_data = PdfDataSerializer(source='*') + seat = InlineSeatSerializer(read_only=True) class Meta: model = OrderPosition fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', - 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data') + 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -430,11 +437,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer): addon_to = serializers.IntegerField(required=False, allow_null=True) secret = serializers.CharField(required=False) attendee_name = serializers.CharField(required=False, allow_null=True) + seat = serializers.CharField(required=False, allow_null=True) class Meta: model = OrderPosition fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', - 'secret', 'addon_to', 'subevent', 'answers') + 'secret', 'addon_to', 'subevent', 'answers', 'seat') def validate_secret(self, secret): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): @@ -615,8 +623,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer): ia = None with self.context['event'].lock() as now_dt: - quotadiff = Counter() - + free_seats = set() + seats_seen = set() consume_carts = validated_data.pop('consume_carts', []) delete_cps = [] quota_avail_cache = {} @@ -630,7 +638,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer): if quota_avail_cache[quota][1] is not None: quota_avail_cache[quota][1] += 1 if cp.expires > now_dt: - quotadiff.subtract(quotas) + if cp.seat: + free_seats.add(cp.seat) delete_cps.append(cp) errs = [{} for p in positions_data] @@ -658,7 +667,22 @@ class OrderCreateSerializer(I18nAwareModelSerializer): ) ] - quotadiff.update(new_quotas) + for i, pos_data in enumerate(positions_data): + seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists() + if pos_data.get('seat'): + if not seated: + errs[i]['seat'] = ['The specified product does not allow to choose a seat.'] + try: + seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent')) + except Seat.DoesNotExist: + errs[i]['seat'] = ['The specified seat does not exist.'] + else: + pos_data['seat'] = seat + if (seat not in free_seats and not seat.is_available()) or seat in seats_seen: + errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)] + seats_seen.add(seat) + elif seated: + errs[i]['seat'] = ['The specified product requires to choose a seat.'] if any(errs): raise ValidationError({'positions': errs}) diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 3fa16488df..be61a0bfd8 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -1,8 +1,20 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer -from pretix.base.models import Organizer +from pretix.api.serializers.order import CompatibleJSONField +from pretix.base.models import Organizer, SeatingPlan +from pretix.base.models.seating import SeatingPlanLayoutValidator class OrganizerSerializer(I18nAwareModelSerializer): class Meta: model = Organizer fields = ('name', 'slug') + + +class SeatingPlanSerializer(I18nAwareModelSerializer): + layout = CompatibleJSONField( + validators=[SeatingPlanLayoutValidator()] + ) + + class Meta: + model = SeatingPlan + fields = ('id', 'name', 'layout') diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index b4527644d0..4397688d4a 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -18,6 +18,7 @@ orga_router = routers.DefaultRouter() orga_router.register(r'events', event.EventViewSet) orga_router.register(r'subevents', event.SubEventViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet) +orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) event_router = routers.DefaultRouter() event_router.register(r'subevents', event.SubEventViewSet) diff --git a/src/pretix/api/views/cart.py b/src/pretix/api/views/cart.py index 4ada236d16..89325a1ac8 100644 --- a/src/pretix/api/views/cart.py +++ b/src/pretix/api/views/cart.py @@ -24,7 +24,7 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly return CartPosition.objects.filter( event=self.request.event, cart_id__endswith="@api" - ) + ).select_related('seat').prefetch_related('answers') def get_serializer_context(self): ctx = super().get_serializer_context() diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 4558eaca6c..7648723bcb 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -231,7 +231,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): ) )) ).select_related( - 'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address' + 'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat' ) else: qs = qs.prefetch_related( @@ -241,7 +241,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): ), 'answers', 'answers__options', 'answers__question', Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) - ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order') + ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat') if not self.checkinlist.all_products: qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True)) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index 67fb1ae7ad..eb83631334 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -86,7 +86,7 @@ class EventViewSet(viewsets.ModelViewSet): ) return qs.prefetch_related( - 'meta_values', 'meta_values__property' + 'meta_values', 'meta_values__property', 'seat_category_mappings' ) def perform_update(self, serializer): @@ -242,7 +242,7 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): event__in=self.request.user.get_events_with_any_permission() ) return qs.prefetch_related( - 'subeventitem_set', 'subeventitemvariation_set' + 'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings' ) def perform_update(self, serializer): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 689835a256..c43ed98a53 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -93,8 +93,8 @@ class OrderViewSet(viewsets.ModelViewSet): 'positions', OrderPosition.objects.all().prefetch_related( 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', - 'item__category', 'addon_to', - Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) + 'item__category', 'addon_to', 'seat', + Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat')) ) ) ) @@ -103,7 +103,7 @@ class OrderViewSet(viewsets.ModelViewSet): Prefetch( 'positions', OrderPosition.objects.all().prefetch_related( - 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', + 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat', ) ) ) @@ -611,13 +611,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS ) )) ).select_related( - 'item', 'variation', 'item__category', 'addon_to' + 'item', 'variation', 'item__category', 'addon_to', 'seat' ) else: qs = qs.prefetch_related( 'checkins', 'answers', 'answers__options', 'answers__question' ).select_related( - 'item', 'order', 'order__event', 'order__event__organizer' + 'item', 'order', 'order__event', 'order__event__organizer', 'seat' ) return qs diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index 8a15d5c3da..db6b5bdfb9 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -1,8 +1,12 @@ from rest_framework import filters, viewsets +from rest_framework.exceptions import PermissionDenied from pretix.api.models import OAuthAccessToken -from pretix.api.serializers.organizer import OrganizerSerializer -from pretix.base.models import Organizer +from pretix.api.serializers.organizer import ( + OrganizerSerializer, SeatingPlanSerializer, +) +from pretix.base.models import Organizer, SeatingPlan +from pretix.helpers.dicts import merge_dicts class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): @@ -30,3 +34,50 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): return Organizer.objects.filter(pk=self.request.auth.organizer_id) else: return Organizer.objects.filter(pk=self.request.auth.team.organizer_id) + + +class SeatingPlanViewSet(viewsets.ModelViewSet): + serializer_class = SeatingPlanSerializer + queryset = SeatingPlan.objects.none() + permission = 'can_change_organizer_settings' + write_permission = 'can_change_organizer_settings' + + def get_queryset(self): + return self.request.organizer.seating_plans.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + def perform_create(self, serializer): + inst = serializer.save(organizer=self.request.organizer) + self.request.organizer.log_action( + 'pretix.seatingplan.added', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': inst.pk}) + ) + + def perform_update(self, serializer): + if serializer.instance.events.exists() or serializer.instance.subevents.exists(): + raise PermissionDenied('This plan can not be changed while it is in use for an event.') + inst = serializer.save(organizer=self.request.organizer) + self.request.organizer.log_action( + 'pretix.seatingplan.changed', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': serializer.instance.pk}) + ) + return inst + + def perform_destroy(self, instance): + if instance.events.exists() or instance.subevents.exists(): + raise PermissionDenied('This plan can not be deleted while it is in use for an event.') + instance.log_action( + 'pretix.seatingplan.deleted', + user=self.request.user, + auth=self.request.auth, + data={'id': instance.pk} + ) + instance.delete() diff --git a/src/pretix/base/migrations/0123_auto_20190530_1035.py b/src/pretix/base/migrations/0123_auto_20190530_1035.py new file mode 100644 index 0000000000..769c422d15 --- /dev/null +++ b/src/pretix/base/migrations/0123_auto_20190530_1035.py @@ -0,0 +1,70 @@ +# Generated by Django 2.2.1 on 2019-05-30 10:35 + +import django.db.models.deletion +from django.db import migrations, models + +import pretix.base.models.base + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0122_orderposition_web_secret'), + ] + + operations = [ + migrations.CreateModel( + name='SeatingPlan', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=190)), + ('layout', models.TextField()), + ('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seating_plans', to='pretixbase.Organizer')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='SeatCategoryMapping', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('layout_category', models.CharField(max_length=190)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Event')), + ('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Item')), + ('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.SubEvent')), + ], + ), + migrations.CreateModel( + name='Seat', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)), + ('name', models.CharField(max_length=190)), + ('blocked', models.BooleanField(default=False)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Event')), + ('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Item')), + ('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.SubEvent')), + ], + ), + migrations.AddField( + model_name='cartposition', + name='seat', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'), + ), + migrations.AddField( + model_name='event', + name='seating_plan', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.SeatingPlan'), + ), + migrations.AddField( + model_name='orderposition', + name='seat', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'), + ), + migrations.AddField( + model_name='subevent', + name='seating_plan', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.SeatingPlan'), + ), + ] diff --git a/src/pretix/base/migrations/0124_seat_seat_guid.py b/src/pretix/base/migrations/0124_seat_seat_guid.py new file mode 100644 index 0000000000..91e9c35b22 --- /dev/null +++ b/src/pretix/base/migrations/0124_seat_seat_guid.py @@ -0,0 +1,19 @@ +# Generated by Django 2.2.1 on 2019-05-30 11:10 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0123_auto_20190530_1035'), + ] + + operations = [ + migrations.AddField( + model_name='seat', + name='seat_guid', + field=models.CharField(db_index=True, default=None, max_length=190), + preserve_default=False, + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index fb6da192dc..82263b51cf 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -24,6 +24,7 @@ from .orders import ( from .organizer import ( Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, ) +from .seating import Seat, SeatCategoryMapping, SeatingPlan from .tax import TaxRule from .vouchers import Voucher from .waitinglist import WaitingListEntry diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 3c69fdd7d8..a4d0733a86 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -336,6 +336,8 @@ class Event(EventMixin, LoggedModel): verbose_name=_('Event series'), default=False ) + seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True, + related_name='events') objects = ScopedManager(organizer='organizer') @@ -348,6 +350,26 @@ class Event(EventMixin, LoggedModel): def __str__(self): return str(self.name) + @property + def free_seats(self): + from .orders import CartPosition, Order, OrderPosition + return self.seats.annotate( + has_order=Exists( + OrderPosition.objects.filter( + order__event=self, + seat_id=OuterRef('pk'), + order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID] + ) + ), + has_cart=Exists( + CartPosition.objects.filter( + event=self, + seat_id=OuterRef('pk'), + expires__gte=now() + ) + ) + ).filter(has_order=False, has_cart=False, blocked=False) + @property def presale_has_ended(self): if self.has_subevents: @@ -531,6 +553,24 @@ class Event(EventMixin, LoggedModel): for i in items: cl.limit_products.add(item_map[i.pk]) + if other.seating_plan: + if other.seating_plan.organizer_id == self.organizer_id: + self.seating_plan = other.seating_plan + else: + self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout) + self.save() + + for m in other.seat_category_mappings.filter(subevent__isnull=True): + m.pk = None + m.event = self + m.product = item_map[m.product_id] + m.save() + + for s in other.seats.filter(subevent__isnull=True): + s.pk = None + s.event = self + s.save() + for s in other.settings._objects.all(): s.object = self s.pk = None @@ -874,6 +914,8 @@ class SubEvent(EventMixin, LoggedModel): null=True, blank=True, verbose_name=_("Frontpage text") ) + seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True, + related_name='subevents') items = models.ManyToManyField('Item', through='SubEventItem') variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation') @@ -888,6 +930,28 @@ class SubEvent(EventMixin, LoggedModel): def __str__(self): return '{} - {}'.format(self.name, self.get_date_range_display()) + @property + def free_seats(self): + from .orders import CartPosition, Order, OrderPosition + return self.seats.annotate( + has_order=Exists( + OrderPosition.objects.filter( + order__event_id=self.event_id, + subevent=self, + seat_id=OuterRef('pk'), + order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID] + ) + ), + has_cart=Exists( + CartPosition.objects.filter( + event_id=self.event_id, + subevent=self, + seat_id=OuterRef('pk'), + expires__gte=now() + ) + ) + ).filter(has_order=False, has_cart=False, blocked=False) + @cached_property def settings(self): return self.event.settings diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 22d9d07ca9..bf4402b896 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -630,7 +630,7 @@ class Order(LockModel, LoggedModel): ), tz) return term_last - def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]: + def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]: error_messages = { 'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " "payment settings is over."), @@ -638,29 +638,37 @@ class Order(LockModel, LoggedModel): "payments should be accepted in the payment settings."), 'require_approval': _('This order is not yet approved by the event organizer.') } - if self.require_approval: - return error_messages['require_approval'] - term_last = self.payment_term_last - if term_last and not ignore_date: - if now() > term_last: - return error_messages['late_lastdate'] + if not force: + if self.require_approval: + return error_messages['require_approval'] + term_last = self.payment_term_last + if term_last and not ignore_date: + if now() > term_last: + return error_messages['late_lastdate'] if self.status == self.STATUS_PENDING: return True - if not self.event.settings.get('payment_term_accept_late') and not ignore_date: + if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force: return error_messages['late'] - return self._is_still_available(count_waitinglist=count_waitinglist) + return self._is_still_available(count_waitinglist=count_waitinglist, force=force) - def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]: + def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]: error_messages = { 'unavailable': _('The ordered product "{item}" is no longer available.'), + 'seat_unavailable': _('The seat "{seat}" is no longer available.'), } now_dt = now_dt or now() - positions = self.positions.all().select_related('item', 'variation') + positions = self.positions.all().select_related('item', 'variation', 'seat') quota_cache = {} try: for i, op in enumerate(positions): + if op.seat: + if not op.seat.is_available(ignore_orderpos=op): + raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat)) + if force: + continue + quotas = list(op.quotas) if len(quotas) == 0: raise Quota.QuotaExceededException(error_messages['unavailable'].format( @@ -938,6 +946,8 @@ class AbstractPosition(models.Model): :type voucher: Voucher :param meta_info: Additional meta information on the position, JSON-encoded. :type meta_info: str + :param seat: Seat, if reserved seating is used. + :type seat: Seat """ subevent = models.ForeignKey( SubEvent, @@ -984,6 +994,9 @@ class AbstractPosition(models.Model): verbose_name=_("Meta information"), null=True, blank=True ) + seat = models.ForeignKey( + 'Seat', null=True, blank=True, on_delete=models.PROTECT + ) class Meta: abstract = True @@ -1183,8 +1196,8 @@ class OrderPayment(models.Model): def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False): from pretix.base.signals import order_paid - can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date) - if not force and can_be_paid is not True: + can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force) + if can_be_paid is not True: self.order.log_action('pretix.event.order.quotaexceeded', { 'message': can_be_paid }, user=user, auth=auth) diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py new file mode 100644 index 0000000000..a62923fec4 --- /dev/null +++ b/src/pretix/base/models/seating.py @@ -0,0 +1,111 @@ +import json +from collections import namedtuple + +import jsonschema +from django.contrib.staticfiles import finders +from django.core.exceptions import ValidationError +from django.db import models +from django.utils.deconstruct import deconstructible +from django.utils.timezone import now +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent + + +@deconstructible +class SeatingPlanLayoutValidator: + def __call__(self, value): + if not isinstance(value, dict): + try: + val = json.loads(value) + except ValueError: + raise ValidationError(_('Your layout file is not a valid JSON file.')) + else: + val = value + with open(finders.find('seating/seating-plan.schema.json'), 'r') as f: + schema = json.loads(f.read()) + try: + jsonschema.validate(val, schema) + except jsonschema.ValidationError as e: + raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(str(e))) + + +class SeatingPlan(LoggedModel): + """ + Represents an abstract seating plan, without relation to any event. + """ + name = models.CharField(max_length=190, verbose_name=_('Name')) + organizer = models.ForeignKey(Organizer, related_name='seating_plans', on_delete=models.CASCADE) + layout = models.TextField(validators=[SeatingPlanLayoutValidator()]) + + Category = namedtuple('Categrory', 'name') + RawSeat = namedtuple('Seat', 'name guid number row category') + + def __str__(self): + return self.name + + @property + def layout_data(self): + return json.loads(self.layout) + + @layout_data.setter + def layout_data(self, v): + self.layout = json.dumps(v) + + def get_categories(self): + return [ + self.Category(name=c['name']) + for c in self.layout_data['categories'] + ] + + def iter_all_seats(self): + for z in self.layout_data['zones']: + for r in z['rows']: + for s in r['seats']: + yield self.RawSeat( + number=s['seat_number'], + guid=s['seat_guid'], + name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme? + row=r['row_number'], + category=s['category'] + ) + + +class SeatCategoryMapping(models.Model): + """ + Input seating plans have abstract "categories", such as "Balcony seat", etc. This model maps them to actual + pretix product on a per-(sub)event level. + """ + event = models.ForeignKey(Event, related_name='seat_category_mappings', on_delete=models.CASCADE) + subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seat_category_mappings', on_delete=models.CASCADE) + layout_category = models.CharField(max_length=190) + product = models.ForeignKey(Item, related_name='seat_category_mappings', on_delete=models.CASCADE) + + +class Seat(models.Model): + """ + This model is used to represent every single specific seat within an (sub)event that can be selected. It's mainly + used for internal bookkeeping and not to be modified by users directly. + """ + event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE) + subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE) + name = models.CharField(max_length=190) + seat_guid = models.CharField(max_length=190, db_index=True) + product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE) + blocked = models.BooleanField(default=False) + + def __str__(self): + return self.name + + def is_available(self, ignore_cart=None, ignore_orderpos=None): + from .orders import Order + + if self.blocked: + return False + opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]) + cpqs = self.cartposition_set.filter(expires__gte=now()) + if ignore_cart: + cpqs = cpqs.exclude(pk=ignore_cart.pk) + if ignore_orderpos: + opqs = opqs.exclude(pk=ignore_orderpos.pk) + return not opqs.exists() and not cpqs.exists() diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 75166b6c29..3c81b89b06 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -10,6 +10,8 @@ from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django_scopes import ScopedManager, scopes_disabled +from pretix.base.models import SeatCategoryMapping + from ..decimal import round_decimal from .base import LoggedModel from .event import Event, SubEvent @@ -395,3 +397,11 @@ class Voucher(LoggedModel): """ return Order.objects.filter(all_positions__voucher__in=[self]).distinct() + + def seating_available(self): + kwargs = {} + if self.subevent: + kwargs['subevent'] = self.subevent + if self.quota_id: + return SeatCategoryMapping.objects.filter(product__quotas__pk=self.quota_id, **kwargs).exists() + return self.item.seat_category_mappings.filter(**kwargs).exists() diff --git a/src/pretix/base/pdf.py b/src/pretix/base/pdf.py index f246f4f3f4..23b69ec5c7 100644 --- a/src/pretix/base/pdf.py +++ b/src/pretix/base/pdf.py @@ -238,6 +238,11 @@ DEFAULT_VARIABLES = OrderedDict(( "TIME_FORMAT" ) if ev.date_admission else "" }), + ("seat", { + "label": _("Seat name"), + "editor_sample": _("3, 4-5"), + "evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission')) + }), )) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 378beacace..cdb8718600 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -6,7 +6,7 @@ from typing import List, Optional from celery.exceptions import MaxRetriesExceededError from django.core.exceptions import ValidationError from django.db import DatabaseError, transaction -from django.db.models import Q +from django.db.models import Count, Exists, OuterRef, Q from django.dispatch import receiver from django.utils.timezone import make_aware, now from django.utils.translation import pgettext_lazy, ugettext as _ @@ -14,8 +14,8 @@ from django_scopes import scopes_disabled from pretix.base.i18n import language from pretix.base.models import ( - CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, - Voucher, + CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat, + SeatCategoryMapping, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import OrderFee @@ -91,15 +91,20 @@ error_messages = { 'product %(base)s.'), 'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'), 'bundled_only': _('One of the products you selected can only be bought part of a bundle.'), + 'seat_required': _('You need to select a specific seat.'), + 'seat_invalid': _('Please select a valid seat.'), + 'seat_forbidden': _('You can not select a seat for this position.'), + 'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'), + 'seat_multiple': _('You can not select the same seat multiple times.'), } class CartManager: AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas', - 'addon_to', 'subevent', 'includes_tax', 'bundled')) + 'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat')) RemoveOperation = namedtuple('RemoveOperation', ('position',)) ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', - 'quotas', 'subevent')) + 'quotas', 'subevent', 'seat')) order = { RemoveOperation: 10, ExtendOperation: 20, @@ -117,6 +122,7 @@ class CartManager: self._items_cache = {} self._subevents_cache = {} self._variations_cache = {} + self._seated_cache = {} self._expiry = None self.invoice_address = invoice_address self._widget_data = widget_data or {} @@ -128,6 +134,11 @@ class CartManager: Q(cart_id=self.cart_id) & Q(event=self.event) ).select_related('item', 'subevent') + def _is_seated(self, item, subevent): + if (item, subevent) not in self._seated_cache: + self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists() + return self._seated_cache[item, subevent] + def _calculate_expiry(self): self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) @@ -188,6 +199,8 @@ class CartManager: i.pk: i for i in self.event.items.select_related('category').prefetch_related( 'addons', 'bundles', 'addons__addon_category', 'quotas' + ).annotate( + has_variations=Count('variations'), ).filter( id__in=[i for i in item_ids if i and i not in self._items_cache] ) @@ -224,6 +237,12 @@ class CartManager: if self._sales_channel not in op.item.sales_channels: raise CartError(error_messages['unavailable']) + if op.item.has_variations and not op.variation: + raise CartError(error_messages['not_for_sale']) + + if op.variation and op.variation.item_id != op.item.pk: + raise CartError(error_messages['not_for_sale']) + if op.voucher and not op.voucher.applies_to(op.item, op.variation): raise CartError(error_messages['voucher_invalid_item']) @@ -239,6 +258,16 @@ class CartManager: if op.subevent and op.subevent.presale_has_ended: raise CartError(error_messages['ended']) + seated = self._is_seated(op.item, op.subevent) + if seated and (not op.seat or op.seat.blocked): + raise CartError(error_messages['seat_invalid']) + elif op.seat and not seated: + raise CartError(error_messages['seat_forbidden']) + elif op.seat and op.seat.product != op.item: + raise CartError(error_messages['seat_invalid']) + elif op.seat and op.count > 1: + raise CartError('Invalid request: A seat can only be bought once.') + if op.subevent: tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) if tlv: @@ -301,6 +330,13 @@ class CartManager: def extend_expired_positions(self): expired = self.positions.filter(expires__lte=self.now_dt).select_related( 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item' + ).annotate( + requires_seat=Exists( + SeatCategoryMapping.objects.filter( + Q(product=OuterRef('item')) + & (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True)) + ) + ) ).prefetch_related( 'item__quotas', 'variation__quotas', @@ -313,6 +349,8 @@ class CartManager: if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions): continue + cp.item.requires_seat = cp.requires_seat + if cp.is_bundled: try: bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation) @@ -359,7 +397,7 @@ class CartManager: op = self.ExtendOperation( position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1, - price=price, quotas=quotas, subevent=cp.subevent + price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat ) self._check_item_constraints(op) @@ -378,12 +416,6 @@ class CartManager: operations = [] for i in items: - # Check whether the specified items are part of what we just fetched from the database - # If they are not, the user supplied item IDs which either do not exist or belong to - # a different event - if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache): - raise CartError(error_messages['not_for_sale']) - if self.event.has_subevents: if not i.get('subevent'): raise CartError(error_messages['subevent_required']) @@ -391,6 +423,24 @@ class CartManager: else: subevent = None + # When a seat is given, we ignore the item that was given, since we can infer it from the + # seat. The variation is still relevant, though! + seat = None + if i.get('seat'): + try: + seat = (subevent or self.event).seats.get(seat_guid=i.get('seat')) + except Seat.DoesNotExist: + raise CartError(error_messages['seat_invalid']) + i['item'] = seat.product_id + if i['item'] not in self._items_cache: + self._update_items_cache([i['item']], [i['variation']]) + + # Check whether the specified items are part of what we just fetched from the database + # If they are not, the user supplied item IDs which either do not exist or belong to + # a different event + if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache): + raise CartError(error_messages['not_for_sale']) + item = self._items_cache[i['item']] variation = self._variations_cache[i['variation']] if i['variation'] is not None else None voucher = None @@ -446,7 +496,7 @@ class CartManager: bop = self.AddOperation( count=bundle.count, item=bitem, variation=bvar, price=bprice, voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent, - includes_tax=bool(bprice.rate), bundled=[] + includes_tax=bool(bprice.rate), bundled=[], seat=None ) self._check_item_constraints(bop) bundled.append(bop) @@ -455,7 +505,7 @@ class CartManager: op = self.AddOperation( count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas, - addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled + addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat ) self._check_item_constraints(op) operations.append(op) @@ -561,7 +611,7 @@ class CartManager: op = self.AddOperation( count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, - addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[] + addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=cp.seat ) self._check_item_constraints(op) operations.append(op) @@ -687,6 +737,7 @@ class CartManager: err = err or self._check_min_per_product() self._operations.sort(key=lambda a: self.order[type(a)]) + seats_seen = set() for op in self._operations: if isinstance(op, self.RemoveOperation): @@ -700,6 +751,11 @@ class CartManager: # Create a CartPosition for as much items as we can requested_count = quota_available_count = voucher_available_count = op.count + if op.seat: + if op.seat in seats_seen: + err = err or error_messages['seat_multiple'] + seats_seen.add(op.seat) + if op.quotas: quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas)) @@ -745,12 +801,16 @@ class CartManager: available_count = 0 if isinstance(op, self.AddOperation): + if op.seat and not op.seat.is_available(): + available_count = 0 + err = err or error_messages['seat_unavailable'] + for k in range(available_count): cp = CartPosition( event=self.event, item=op.item, variation=op.variation, price=op.price.gross, expires=self._expiry, cart_id=self.cart_id, voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None, - subevent=op.subevent, includes_tax=op.includes_tax + subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat ) if self.event.settings.attendee_names_asked: scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme) @@ -789,7 +849,11 @@ class CartManager: new_cart_positions.append(cp) elif isinstance(op, self.ExtendOperation): - if available_count == 1: + if op.seat and not op.seat.is_available(ignore_cart=op.position): + err = err or error_messages['seat_unavailable'] + op.position.addons.all().delete() + op.position.delete() + elif available_count == 1: op.position.expires = self._expiry op.position.price = op.price.gross try: @@ -820,6 +884,9 @@ class CartManager: # If any quotas are affected that are not unlimited, we lock return True + if any(getattr(o, 'seat', False) for o in self._operations): + return True + return False def commit(self): @@ -909,7 +976,7 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo """ Adds a list of items to a user's cart. :param event: The event ID in question - :param items: A list of dicts with the keys item, variation, count, custom_price, voucher + :param items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID :param cart_id: Session ID of a guest :raises CartError: On any error that occured """ diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e07221773f..81a2cdcfa4 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -24,7 +24,7 @@ from pretix.base.i18n import ( ) from pretix.base.models import ( CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment, - OrderPosition, Quota, User, Voucher, + OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.items import ItemBundle @@ -82,6 +82,8 @@ error_messages = { 'affected positions have been removed from your cart.'), 'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected ' 'positions have been removed from your cart.'), + 'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'), + 'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'), } logger = logging.getLogger(__name__) @@ -428,6 +430,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio products_seen = Counter() changed_prices = {} deleted_positions = set() + seats_seen = set() def delete(cp): # Delete a cart position, including parents and children, if applicable @@ -490,6 +493,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio delete(cp) break + if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen: + err = err or error_messages['seat_invalid'] + delete(cp) + break + if cp.seat: + seats_seen.add(cp.seat) + if cp.item.require_voucher and cp.voucher is None: delete(cp) err = err or error_messages['voucher_required'] @@ -501,6 +511,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio err = error_messages['voucher_required'] break + if cp.seat: + # Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely + # can not overbook a seat. + if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked: + err = err or error_messages['seat_unavailable'] + cp.delete() + continue + if cp.expires >= now_dt and not cp.voucher: # Other checks are not necessary continue @@ -736,21 +754,30 @@ def _perform_order(event: Event, payment_provider: str, position_ids: List[str], except InvoiceAddress.DoesNotExist: pass - positions = CartPosition.objects.filter(id__in=position_ids, event=event) + positions = CartPosition.objects.annotate( + requires_seat=Exists( + SeatCategoryMapping.objects.filter( + Q(product=OuterRef('item')) + & (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True)) + ) + ) + ).filter( + id__in=position_ids, event=event + ) validate_order.send(event, payment_provider=pprov, email=email, positions=positions, locale=locale, invoice_address=addr, meta_info=meta_info) lockfn = NoLockManager locked = False - if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists(): + if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists(): # Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date, # creating this order shouldn't be prone to any race conditions and we don't need to lock the event. locked = True lockfn = event.lock with lockfn() as now_dt: - positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons')) + positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')) if len(positions) == 0: raise OrderError(error_messages['empty']) if len(position_ids) != len(positions): @@ -961,12 +988,17 @@ class OrderChangeManager: 'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'), 'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'), 'subevent_required': _('You need to choose a subevent for the new position.'), + 'seat_unavailable': _('The selected seat "{seat}" is not available.'), + 'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'), + 'seat_required': _('The selected product requires you to select a seat.'), + 'seat_forbidden': _('The selected product does not allow to select a seat.'), } ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation')) SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) + SeatOperation = namedtuple('SubeventOperation', ('position', 'seat')) PriceOperation = namedtuple('PriceOperation', ('position', 'price')) CancelOperation = namedtuple('CancelOperation', ('position',)) - AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent')) + AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat')) SplitOperation = namedtuple('SplitOperation', ('position',)) RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',)) @@ -979,6 +1011,7 @@ class OrderChangeManager: self._committed = False self._totaldiff = 0 self._quotadiff = Counter() + self._seatdiff = Counter() self._operations = [] self.notify = notify self._invoice_dirty = False @@ -996,6 +1029,13 @@ class OrderChangeManager: self._quotadiff.subtract(position.quotas) self._operations.append(self.ItemOperation(position, item, variation)) + def change_seat(self, position: OrderPosition, seat: Seat): + if position.seat: + self._seatdiff.subtract([position.seat]) + if seat: + self._seatdiff.update([seat]) + self._operations.append(self.SeatOperation(position, seat)) + def change_subevent(self, position: OrderPosition, subevent: SubEvent): price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent, invoice_address=self._invoice_address) @@ -1051,12 +1091,14 @@ class OrderChangeManager: self._totaldiff += -position.price self._quotadiff.subtract(position.quotas) self._operations.append(self.CancelOperation(position)) + if position.seat: + self._seatdiff.subtract([position.seat]) if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): self._invoice_dirty = True def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, - subevent: SubEvent = None): + subevent: SubEvent = None, seat: Seat = None): if price is None: price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) else: @@ -1075,6 +1117,14 @@ class OrderChangeManager: if self.order.event.has_subevents and not subevent: raise OrderError(self.error_messages['subevent_required']) + seated = item.seat_category_mappings.filter(subevent=subevent).exists() + if seated and not seat: + raise OrderError(self.error_messages['seat_required']) + elif not seated and seat: + raise OrderError(self.error_messages['seat_forbidden']) + if seat and subevent and seat.subevent_id != subevent: + raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name)) + new_quotas = (variation.quotas.filter(subevent=subevent) if variation else item.quotas.filter(subevent=subevent)) if not new_quotas: @@ -1085,7 +1135,9 @@ class OrderChangeManager: self._totaldiff += price.gross self._quotadiff.update(new_quotas) - self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent)) + if seat: + self._seatdiff.update([seat]) + self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat)) def split(self, position: OrderPosition): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): @@ -1093,6 +1145,26 @@ class OrderChangeManager: self._operations.append(self.SplitOperation(position)) + def _check_seats(self): + for seat, diff in self._seatdiff.items(): + if diff <= 0: + continue + if not seat.is_available() or diff > 1: + raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name)) + + if self.event.has_subevents: + state = {} + for p in self.order.positions.all(): + state[p] = {'seat': p.seat, 'subevent': p.subevent} + for op in self._operations: + if isinstance(op, self.SeatOperation): + state[op.position]['seat'] = op.seat + elif isinstance(op, self.SubeventOperation): + state[op.position]['subevent'] = op.subevent + for v in state.values(): + if v['seat'] and v['seat'].subevent_id != v['subevent'].pk: + raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name)) + def _check_quotas(self): for quota, diff in self._quotadiff.items(): if diff <= 0: @@ -1179,6 +1251,17 @@ class OrderChangeManager: op.position.variation = op.variation op.position._calculate_tax() op.position.save() + elif isinstance(op, self.SeatOperation): + self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={ + 'position': op.position.pk, + 'positionid': op.position.positionid, + 'old_seat': op.position.seat.name if op.position.seat else "-", + 'new_seat': op.seat.name if op.seat else "-", + 'old_seat_id': op.position.seat.pk if op.position.seat else None, + 'new_seat_id': op.seat.pk if op.seat else None, + }) + op.position.seat = op.seat + op.position.save() elif isinstance(op, self.SubeventOperation): self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={ 'position': op.position.pk, @@ -1232,7 +1315,7 @@ class OrderChangeManager: item=op.item, variation=op.variation, addon_to=op.addon_to, price=op.price.gross, order=self.order, tax_rate=op.price.rate, tax_value=op.price.tax, tax_rule=op.item.tax_rule, - positionid=nextposid, subevent=op.subevent + positionid=nextposid, subevent=op.subevent, seat=op.seat ) nextposid += 1 self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ @@ -1243,6 +1326,7 @@ class OrderChangeManager: 'price': op.price.gross, 'positionid': pos.positionid, 'subevent': op.subevent.pk if op.subevent else None, + 'seat': op.seat.pk if op.seat else None, }) elif isinstance(op, self.SplitOperation): split_positions.append(op.position) @@ -1467,6 +1551,7 @@ class OrderChangeManager: raise OrderError(self.error_messages['not_pending_or_paid']) if check_quotas: self._check_quotas() + self._check_seats() self._check_complete_cancel() self._perform_operations() self._recalculate_total_and_payment_fee() diff --git a/src/pretix/base/services/seating.py b/src/pretix/base/services/seating.py new file mode 100644 index 0000000000..1526c4e2a7 --- /dev/null +++ b/src/pretix/base/services/seating.py @@ -0,0 +1,57 @@ +from django.db.models import Count +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.models import CartPosition, Seat + + +class SeatProtected(Exception): + pass + + +def validate_plan_change(event, subevent, plan): + current_taken_seats = set( + event.seats.select_related('product') + .annotate(has_op=Count('orderposition')) + .filter(subevent=subevent, has_op=True) + .values_list('seat_guid', flat=True) + ) + new_seats = { + ss.guid for ss in plan.iter_all_seats() + } if plan else set() + leftovers = list(current_taken_seats - new_seats) + if leftovers: + raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is ' + 'already sold.').format(leftovers[0])) + + +def generate_seats(event, subevent, plan, mapping): + current_seats = { + s.seat_guid: s for s in + event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent) + } + create_seats = [] + if plan: + for ss in plan.iter_all_seats(): + p = mapping.get(ss.category) + if ss.guid in current_seats: + seat = current_seats.pop(ss.guid) + if seat.product != p: + seat.product = p + seat.save() + else: + create_seats.append(Seat( + event=event, + subevent=subevent, + seat_guid=ss.guid, + name=ss.name, + product=p, + )) + + for s in current_seats.values(): + if s.has_op: + raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is ' + 'already sold.').format(s.name)) + + Seat.objects.bulk_create(create_seats) + CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete() + Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete() diff --git a/src/pretix/control/forms/orders.py b/src/pretix/control/forms/orders.py index f1af1e20ed..202e981353 100644 --- a/src/pretix/control/forms/orders.py +++ b/src/pretix/control/forms/orders.py @@ -10,7 +10,9 @@ from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from pretix.base.forms import I18nModelForm, PlaceholderValidator from pretix.base.forms.widgets import DatePickerWidget -from pretix.base.models import InvoiceAddress, ItemAddOn, Order, OrderPosition +from pretix.base.models import ( + InvoiceAddress, ItemAddOn, Order, OrderPosition, Seat, +) from pretix.base.models.event import SubEvent from pretix.base.services.pricing import get_price from pretix.control.forms.widgets import Select2 @@ -196,6 +198,12 @@ class OrderPositionAddForm(forms.Form): required=False, label=_('Add-on to'), ) + seat = forms.ModelChoiceField( + Seat.objects.none(), + required=False, + label=_('Seat'), + empty_label=_('General admission') + ) price = forms.DecimalField( required=False, max_digits=10, decimal_places=2, @@ -241,6 +249,19 @@ class OrderPositionAddForm(forms.Form): else: del self.fields['addon_to'] + self.fields['seat'].queryset = order.event.seats.all() + self.fields['seat'].widget = Select2( + attrs={ + 'data-model-select2': 'seat', + 'data-select2-url': reverse('control:event.seats.select2', kwargs={ + 'event': order.event.slug, + 'organizer': order.event.organizer.slug, + }), + 'data-placeholder': _('General admission') + } + ) + self.fields['seat'].widget.choices = self.fields['seat'].choices + if order.event.has_subevents: self.fields['subevent'].queryset = order.event.subevents.all() self.fields['subevent'].widget = Select2( @@ -269,6 +290,11 @@ class OrderPositionChangeForm(forms.Form): required=False, empty_label=_('(Unchanged)') ) + seat = forms.ModelChoiceField( + Seat.objects.none(), + required=False, + empty_label=_('(Unchanged)') + ) price = forms.DecimalField( required=False, max_digits=10, decimal_places=2, @@ -312,6 +338,22 @@ class OrderPositionChangeForm(forms.Form): else: del self.fields['subevent'] + if instance.seat: + self.fields['seat'].queryset = instance.order.event.seats.all() + self.fields['seat'].widget = Select2( + attrs={ + 'data-model-select2': 'seat', + 'data-select2-url': reverse('control:event.seats.select2', kwargs={ + 'event': instance.order.event.slug, + 'organizer': instance.order.event.organizer.slug, + }), + 'data-placeholder': _('(Unchanged)') + } + ) + self.fields['seat'].widget.choices = self.fields['seat'].choices + else: + del self.fields['seat'] + choices = [ ('', _('(Unchanged)')) ] diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index c82cd6e29f..e50cc38d0a 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -36,7 +36,7 @@ class SubEventForm(I18nModelForm): 'presale_start', 'presale_end', 'location', - 'frontpage_text' + 'frontpage_text', ] field_classes = { 'date_from': SplitDateTimeField, diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 727e5f98ad..aa23ae6b9d 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -42,6 +42,12 @@ def _display_order_changed(event: Event, logentry: LogEntry): old_price=money_filter(Decimal(data['old_price']), event.currency), new_price=money_filter(Decimal(data['new_price']), event.currency), ) + elif logentry.action_type == 'pretix.event.order.changed.seat': + return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed ' + 'to "{new_seat}".').format( + posid=data.get('positionid', '?'), + old_seat=data.get('old_seat'), new_seat=data.get('new_seat'), + ) elif logentry.action_type == 'pretix.event.order.changed.subevent': old_se = str(event.subevents.get(pk=data['old_subevent'])) new_se = str(event.subevents.get(pk=data['new_subevent'])) diff --git a/src/pretix/control/signals.py b/src/pretix/control/signals.py index de58fb5d30..f9eb4ba9b1 100644 --- a/src/pretix/control/signals.py +++ b/src/pretix/control/signals.py @@ -279,6 +279,22 @@ styles. It is advisable to set a prefix for your form to avoid clashes with othe As with all plugin signals, the ``sender`` keyword argument will contain the event. """ +subevent_forms = EventPluginSignal( + providing_args=['request', 'subevent'] +) +""" +This signal allows you to return additional forms that should be rendered on the subevent creation +or modification page. You are passed ``request`` and ``subevent`` arguments and are expected to return +an instance of a form class that you bind yourself when appropriate. Your form will be executed +as part of the standard validation and rendering cycle and rendered using default bootstrap +styles. It is advisable to set a prefix for your form to avoid clashes with other plugins. + +``subevent`` can be ``None`` during creation. Before ``save()`` is called, a ``subevent`` property of +your form instance will automatically being set to the subevent that has just been created. + +As with all plugin signals, the ``sender`` keyword argument will contain the event. +""" + oauth_application_registered = Signal( providing_args=["user", "application"] ) diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index b299285e08..c6ed2c525d 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -145,7 +145,11 @@ {% if nav.icon %} - + {% if " + {% endif %} {{ nav.label }} {% else %} {{ nav.label }} @@ -270,7 +274,11 @@ {% if nav.icon %} - + {% if " + {% endif %} {% endif %} {{ nav.label }} diff --git a/src/pretix/control/templates/pretixcontrol/order/change.html b/src/pretix/control/templates/pretixcontrol/order/change.html index 883911790a..a0b02f3a04 100644 --- a/src/pretix/control/templates/pretixcontrol/order/change.html +++ b/src/pretix/control/templates/pretixcontrol/order/change.html @@ -72,7 +72,7 @@
-
+
{% bootstrap_form_errors position.form %} {% if position.custom_error %}
@@ -87,20 +87,6 @@ {% trans "Change to" %}
-
-
- {% trans "Product" %} -
-
- {{ position.item }} - {% if position.variation %} - – {{ position.variation }} - {% endif %} -
-
- {% bootstrap_field position.form.itemvar layout='inline' %} -
-
{% if request.event.has_subevents %}
@@ -116,6 +102,34 @@
{% endif %} + {% if position.seat %} +
+
+ {% trans "Seat" %} +
+
+ {{ position.seat }} +
+
+ {% bootstrap_field position.form.seat layout='inline' %} +
+
+ {% endif %} +
+
+ {% trans "Product" %} +
+
+ {{ position.item }} + {% if position.variation %} + – {{ position.variation }} + {% endif %} +
+
+ {% bootstrap_field position.form.itemvar layout='inline' %} +
+
+
{% trans "Price" %} @@ -182,6 +196,7 @@ {% if add_form.subevent %} {% bootstrap_field add_form.subevent layout="control" %} {% endif %} + {% bootstrap_field add_form.seat layout="control" %}
diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 55a63d27df..31e8fe1f8f 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -258,6 +258,15 @@ {% endfor %} {% endif %} + {% if line.seat %} +
+ + + + {{ line.seat }} + {% endif %} {% if line.voucher %}
{% trans "Voucher code used:" %} diff --git a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html index daea7cdfd6..260149b0dd 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/bulk.html @@ -434,6 +434,12 @@

+
+ {% trans "Additional settings" %} + {% for f in plugin_forms %} + {% bootstrap_form f layout="control" %} + {% endfor %} +

+
+ {% trans "Additional settings" %} + {% for f in plugin_forms %} + {% bootstrap_form f layout="control" %} + {% endfor %} +
{% if subevent.pk %}
diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index fb6cd25d2e..0917ce1bb0 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -140,6 +140,7 @@ urlpatterns = [ url(r'^pdf/editor/(?P[^/]+).pdf$', pdf.PdfView.as_view(), name='pdf.background'), url(r'^subevents/$', subevents.SubEventList.as_view(), name='event.subevents'), url(r'^subevents/select2$', typeahead.subevent_select2, name='event.subevents.select2'), + url(r'^seats/select2$', typeahead.seat_select2, name='event.seats.select2'), url(r'^subevents/(?P\d+)/$', subevents.SubEventUpdate.as_view(), name='event.subevent'), url(r'^subevents/(?P\d+)/delete$', subevents.SubEventDelete.as_view(), name='event.subevent.delete'), diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index e6a2af0367..af3e1a06ac 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -1236,7 +1236,8 @@ class OrderChange(OrderView): ocm.add_position(item, variation, self.add_form.cleaned_data['price'], self.add_form.cleaned_data.get('addon_to'), - self.add_form.cleaned_data.get('subevent')) + self.add_form.cleaned_data.get('subevent'), + self.add_form.cleaned_data.get('seat')) except OrderError as e: self.add_form.custom_error = str(e) return False @@ -1266,6 +1267,9 @@ class OrderChange(OrderView): if item != p.item or variation != p.variation: ocm.change_item(p, item, variation) + if p.seat and p.form.cleaned_data['seat'] and p.form.cleaned_data['seat'] != p.seat: + ocm.change_seat(p, p.form.cleaned_data['seat']) + if self.request.event.has_subevents and p.form.cleaned_data['subevent'] and p.form.cleaned_data['subevent'] != p.subevent: ocm.change_subevent(p, p.form.cleaned_data['subevent']) diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index 2630ef73e9..ae75b61172 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -3,6 +3,7 @@ from datetime import datetime from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rruleset from django.contrib import messages +from django.core.files import File from django.db import transaction from django.db.models import F, IntegerField, OuterRef, Prefetch, Subquery, Sum from django.db.models.functions import Coalesce @@ -32,6 +33,7 @@ from pretix.control.forms.subevents import ( SubEventMetaValueForm, ) from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.control.signals import subevent_forms from pretix.control.views import PaginationMixin from pretix.control.views.event import MetaDataEditorMixin from pretix.helpers.models import modelcopy @@ -135,6 +137,16 @@ class SubEventEditorMixin(MetaDataEditorMixin): meta_form = SubEventMetaValueForm meta_model = SubEventMetaValue + @cached_property + def plugin_forms(self): + forms = [] + for rec, resp in subevent_forms.send(sender=self.request.event, subevent=self.object, request=self.request): + if isinstance(resp, (list, tuple)): + forms.extend(resp) + else: + forms.append(resp) + return forms + def _make_meta_form(self, p, val_instances): if not hasattr(self, '_default_meta'): self._default_meta = self.request.event.meta_data @@ -294,6 +306,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): ctx['cl_formset'] = self.cl_formset ctx['itemvar_forms'] = self.itemvar_forms ctx['meta_forms'] = self.meta_forms + ctx['plugin_forms'] = self.plugin_forms return ctx @cached_property @@ -347,7 +360,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): def is_valid(self, form): return form.is_valid() and all([f.is_valid() for f in self.itemvar_forms]) and self.formset.is_valid() and ( all([f.is_valid() for f in self.meta_forms]) - ) and self.cl_formset.is_valid() + ) and self.cl_formset.is_valid() and all(f.is_valid() for f in self.plugin_forms) class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateView): @@ -361,9 +374,9 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi self.object = self.get_object() form = self.get_form() if self.is_valid(form): - return self.form_valid(form) - else: - return self.form_invalid(form) + r = self.form_valid(form) + return r + return self.form_invalid(form) def get_object(self, queryset=None) -> SubEvent: try: @@ -384,12 +397,23 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi # TODO: LogEntry? messages.success(self.request, _('Your changes have been saved.')) - if form.has_changed(): + if form.has_changed() or any(f.has_changed() for f in self.plugin_forms): + data = { + k: form.cleaned_data.get(k) for k in form.changed_data + } + for f in self.plugin_forms: + data.update({ + k: (f.cleaned_data.get(k).name + if isinstance(f.cleaned_data.get(k), File) + else f.cleaned_data.get(k)) + for k in f.changed_data + }) self.object.log_action( - 'pretix.subevent.changed', user=self.request.user, data={ - k: form.cleaned_data.get(k) for k in form.changed_data - } + 'pretix.subevent.changed', user=self.request.user, data=data ) + for f in self.plugin_forms: + f.subevent = self.object + f.save() return super().form_valid(form) def get_success_url(self) -> str: @@ -416,8 +440,7 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi form = self.get_form() if self.is_valid(form): return self.form_valid(form) - else: - return self.form_invalid(form) + return self.form_invalid(form) def get_success_url(self) -> str: return reverse('control:event.subevents', kwargs={ @@ -442,7 +465,16 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi messages.success(self.request, pgettext_lazy('subevent', 'The new date has been created.')) ret = super().form_valid(form) self.object = form.instance - form.instance.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user) + + data = dict(form.cleaned_data) + for f in self.plugin_forms: + data.update({ + k: (f.cleaned_data.get(k).name + if isinstance(f.cleaned_data.get(k), File) + else f.cleaned_data.get(k)) + for k in f.cleaned_data + }) + form.instance.log_action('pretix.subevent.added', data=dict(data), user=self.request.user) self.save_formset(form.instance) self.save_cl_formset(form.instance) @@ -452,6 +484,9 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi for f in self.meta_forms: f.instance.subevent = form.instance self.save_meta() + for f in self.plugin_forms: + f.subevent = form.instance + f.save() return ret @cached_property @@ -657,7 +692,6 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea @transaction.atomic def form_valid(self, form): - tz = self.request.event.timezone cnt = 0 for rdate in self.get_rrule_set(): @@ -685,7 +719,15 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea else None ) se.save() - se.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user) + data = dict(form.cleaned_data) + for f in self.plugin_forms: + data.update({ + k: (f.cleaned_data.get(k).name + if isinstance(f.cleaned_data.get(k), File) + else f.cleaned_data.get(k)) + for k in f.cleaned_data + }) + se.log_action('pretix.subevent.added', data=data, user=self.request.user) for f in self.meta_forms: if f.cleaned_data.get('value'): @@ -731,6 +773,11 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea i.subevent = se i.save() + for f in self.plugin_forms: + f.is_valid() + f.subevent = se + f.save() + cnt += 1 messages.success(self.request, pgettext_lazy('subevent', '{} new dates have been created.').format(cnt)) @@ -742,7 +789,8 @@ class SubEventBulkCreate(SubEventEditorMixin, EventPermissionRequiredMixin, Crea def post(self, request, *args, **kwargs): form = self.get_form() self.object = SubEvent(event=self.request.event) + if self.is_valid(form): return self.form_valid(form) - else: - return self.form_invalid(form) + + return self.form_invalid(form) diff --git a/src/pretix/control/views/typeahead.py b/src/pretix/control/views/typeahead.py index 038f626987..1cf7b34ab3 100644 --- a/src/pretix/control/views/typeahead.py +++ b/src/pretix/control/views/typeahead.py @@ -11,7 +11,7 @@ from django.utils.formats import get_format from django.utils.timezone import make_aware from django.utils.translation import pgettext, ugettext as _ -from pretix.base.models import Order, Organizer, User, Voucher +from pretix.base.models import Order, Organizer, SubEvent, User, Voucher from pretix.control.forms.event import EventWizardCopyForm from pretix.control.permissions import event_permission_required from pretix.helpers.daterange import daterange @@ -221,6 +221,46 @@ def nav_context_list(request): return JsonResponse(doc) +@event_permission_required("can_view_orders") +def seat_select2(request, **kwargs): + query = request.GET.get('query', '') + try: + page = int(request.GET.get('page', '1')) + except ValueError: + page = 1 + + if request.event.has_subevents: + try: + qs = request.event.subevents.get(active=True, pk=request.GET.get('subevent', 0)).free_seats + except SubEvent.DoesNotExist: + qs = request.event.seats.none() + else: + qs = request.event.free_seats + qs = qs.filter( + Q(name__icontains=query) | Q(seat_guid__icontains=query) + ).order_by('name').select_related('product', 'subevent') + + total = qs.count() + pagesize = 20 + offset = (page - 1) * pagesize + doc = { + 'results': [ + { + 'id': e.pk, + 'text': '{} ({})'.format(e.name, str(e.product)), + 'product': e.product_id, + 'event': str(e.subevent) if e.subevent else '' + + } + for e in qs[offset:offset + pagesize] + ], + 'pagination': { + "more": total >= (offset + pagesize) + } + } + return JsonResponse(doc) + + @event_permission_required(None) def subevent_select2(request, **kwargs): query = request.GET.get('query', '') diff --git a/src/pretix/icons/seat.svg b/src/pretix/icons/seat.svg new file mode 100644 index 0000000000..bc50ad539c --- /dev/null +++ b/src/pretix/icons/seat.svg @@ -0,0 +1,6 @@ + + + + diff --git a/src/pretix/presale/signals.py b/src/pretix/presale/signals.py index 1822c2480f..fb15d2e21f 100644 --- a/src/pretix/presale/signals.py +++ b/src/pretix/presale/signals.py @@ -222,6 +222,18 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve receivers are expected to return HTML. """ +render_seating_plan = EventPluginSignal( + providing_args=["request", "subevent", "voucher"] +) +""" +This signal is sent out to render a seating plan, if one is configured for the specific event. +You will be passed the ``request`` as a keyword argument. If applicable, a ``subevent`` or +``voucher`` argument might be given. + +As with all plugin signals, the ``sender`` keyword argument will contain the event. The +receivers are expected to return HTML. +""" + front_page_bottom = EventPluginSignal( providing_args=[] ) diff --git a/src/pretix/presale/templates/pretixpresale/base.html b/src/pretix/presale/templates/pretixpresale/base.html index beb7b5341f..ceaf7e73c7 100644 --- a/src/pretix/presale/templates/pretixpresale/base.html +++ b/src/pretix/presale/templates/pretixpresale/base.html @@ -19,20 +19,7 @@ {% endcompress %} {% endif %} - {% compress js %} - - - - - - - - - - - - - {% endcompress %} + {% include "pretixpresale/fragment_js.html" %} {{ html_head|safe }} @@ -79,20 +66,7 @@ {% include "pretixpresale/base_footer.html" %}
-
-
-
- -
+{% include "pretixpresale/fragment_modals.html" %} {% if DEBUG %} {% else %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 5bf35cdd80..04018b4f6f 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -13,11 +13,20 @@ {% if line.variation %} – {{ line.variation }} {% endif %} + {% if line.seat %} +
+ + + + {{ line.seat }} + {% endif %} {% if line.voucher %} -
{% trans "Voucher code used:" %} {{ line.voucher.code }} +
{% trans "Voucher code used:" %} {{ line.voucher.code }} {% endif %} {% if line.subevent %} -
{{ line.subevent.name }} · {{ line.subevent.get_date_range_display }} +
{{ line.subevent.name }} · {{ line.subevent.get_date_range_display }} {% if event.settings.show_times %} {{ line.subevent.date_from|date:"TIME_FORMAT" }} @@ -116,7 +125,7 @@ {% endif %} - diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html b/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html index c440ce4fcb..8346744c2b 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_product_list.html @@ -141,7 +141,7 @@
{% elif var.cached_availability.0 == 100 %}
- {% if item.max_per_order == 1 %} + {% if item.max_per_order == 1 %}