diff --git a/doc/api/resources/discounts.rst b/doc/api/resources/discounts.rst index e7c126b003..70f5c24006 100644 --- a/doc/api/resources/discounts.rst +++ b/doc/api/resources/discounts.rst @@ -20,8 +20,12 @@ id integer Internal ID active boolean The discount will be ignored if this is ``false`` internal_name string A name for the rule used in the backend position integer An integer, used for sorting the rules which are applied in order -sales_channels list of strings Sales channels this discount is available on, such as - ``"web"`` or ``"resellers"``. Defaults to ``["web"]``. +all_sales_channels boolean If ``true`` (default), the discount is available on all sales channels + that support discounts. +limit_sales_channels list of strings List of sales channel identifiers the discount is available on + if ``all_sales_channels`` is ``false``. +sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels`` + and ``limit_sales_channels`` instead. available_from datetime The first date time at which this discount can be applied (or ``null``). available_until datetime The last date time at which this discount can be applied @@ -95,6 +99,8 @@ Endpoints "active": true, "internal_name": "3 for 2", "position": 1, + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_until": null, @@ -151,6 +157,8 @@ Endpoints "active": true, "internal_name": "3 for 2", "position": 1, + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_until": null, @@ -193,6 +201,8 @@ Endpoints "active": true, "internal_name": "3 for 2", "position": 1, + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_until": null, @@ -224,6 +234,8 @@ Endpoints "active": true, "internal_name": "3 for 2", "position": 1, + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_until": null, @@ -284,6 +296,8 @@ Endpoints "active": false, "internal_name": "3 for 2", "position": 1, + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_until": null, diff --git a/doc/api/resources/events.rst b/doc/api/resources/events.rst index d1a631968a..1fbe079f57 100644 --- a/doc/api/resources/events.rst +++ b/doc/api/resources/events.rst @@ -49,8 +49,11 @@ item_meta_properties object Item-specific m valid_keys object Cryptographic keys for non-default signature schemes. For performance reason, value is omitted in lists and only contained in detail views. Value can be cached. -sales_channels list A list of sales channels this event is available for - sale on. +all_sales_channels boolean If ``true`` (default), the event is available on all sales channels. +limit_sales_channels list of strings List of sales channel identifiers the event is available on + if ``all_sales_channels`` is ``false``. +sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels`` + and ``limit_sales_channels`` instead. public_url string The public, customer-facing URL of the event (read-only). ===================================== ========================== ======================================================= @@ -131,11 +134,13 @@ Endpoints "pretix.plugins.paypal", "pretix.plugins.ticketoutputpdf" ], - "sales_channels": [ + "all_sales_channels": false, + "limit_sales_channels": [ "web", "pretixpos", "resellers" ], + "sales_channels": [], "public_url": "https://pretix.eu/bigevents/sampleconf/" } ] @@ -225,6 +230,8 @@ Endpoints "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=" ] }, + "all_sales_channels": true, + "limit_sales_channels": [], "sales_channels": [ "web", "pretixpos", @@ -282,11 +289,8 @@ Endpoints "pretix.plugins.stripe", "pretix.plugins.paypal" ], - "sales_channels": [ - "web", - "pretixpos", - "resellers" - ] + "all_sales_channels": true, + "limit_sales_channels": [] } **Example response**: @@ -322,6 +326,8 @@ Endpoints "pretix.plugins.stripe", "pretix.plugins.paypal" ], + "all_sales_channels": true, + "limit_sales_channels": [], "sales_channels": [ "web", "pretixpos", @@ -387,11 +393,8 @@ Endpoints "pretix.plugins.stripe", "pretix.plugins.paypal" ], - "sales_channels": [ - "web", - "pretixpos", - "resellers" - ] + "all_sales_channels": true, + "limit_sales_channels": [] } **Example response**: @@ -427,6 +430,8 @@ Endpoints "pretix.plugins.stripe", "pretix.plugins.paypal" ], + "all_sales_channels": true, + "limit_sales_channels": [], "sales_channels": [ "web", "pretixpos", @@ -502,6 +507,8 @@ Endpoints "pretix.plugins.paypal", "pretix.plugins.pretixdroid" ], + "all_sales_channels": true, + "limit_sales_channels": [], "sales_channels": [ "web", "pretixpos", diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index c3a6449b8f..89130bfaa0 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -30,6 +30,7 @@ at :ref:`plugin-docs`. checkinlists waitinglist customers + saleschannels membershiptypes memberships giftcards diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index 22e9df9400..0ae071d897 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -38,11 +38,14 @@ require_membership boolean If ``true``, bo require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will be hidden from users without a valid membership. require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` -sales_channels list of strings Sales channels this variation is available on, such as - ``"web"`` or ``"resellers"``. Defaults to all existing sales channels. +all_sales_channels boolean If ``true`` (default), the variation is available on all sales channels. +limit_sales_channels list of strings List of sales channel identifiers the variation is available on + if ``all_sales_channels`` is ``false``. The item-level list takes precedence, i.e. a sales - channel needs to be on both lists for the item to be - available. + channel needs to be on both lists for the variation to be + available (unless ``all_sales_channels`` is used). +sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels`` + and ``limit_sales_channels`` instead. available_from datetime The first date time at which this variation can be bought (or ``null``). available_from_mode string If ``hide`` (the default), this variation is hidden in the shop @@ -111,6 +114,8 @@ Endpoints "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -139,6 +144,8 @@ Endpoints "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -202,6 +209,8 @@ Endpoints "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -244,7 +253,8 @@ Endpoints "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], - "sales_channels": ["web"], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", "available_until": null, @@ -277,6 +287,8 @@ Endpoints "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -341,6 +353,8 @@ Endpoints "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index c48a5c64c3..24defa7ba0 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -46,8 +46,11 @@ personalized boolean ``true`` for position integer An integer, used for sorting picture file A product picture to be displayed in the shop (can be ``null``). -sales_channels list of strings Sales channels this product is available on, such as - ``"web"`` or ``"resellers"``. Defaults to ``["web"]``. +all_sales_channels boolean If ``true`` (default), the item is available on all sales channels. +limit_sales_channels list of strings List of sales channel identifiers the item is available on + if ``all_sales_channels`` is ``false``. +sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels`` + and ``limit_sales_channels`` instead. available_from datetime The first date time at which this item can be bought (or ``null``). available_from_mode string If ``hide`` (the default), this item is hidden in the shop @@ -157,11 +160,14 @@ variations list of objects A list with o be hidden from users without a valid membership. ├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` Markdown syntax or can be ``null``. -├ sales_channels list of strings Sales channels this variation is available on, such as - ``"web"`` or ``"resellers"``. Defaults to all existing sales channels. +├ all_sales_channels boolean If ``true`` (default), the variation is available on all sales channels. +├ limit_sales_channels list of strings List of sales channel identifiers the variation is available on + if ``all_sales_channels`` is ``false``. The item-level list takes precedence, i.e. a sales - channel needs to be on both lists for the item to be - available. + channel needs to be on both lists for the variation to be + available (unless ``all_sales_channels`` is used). +├ sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels`` + and ``limit_sales_channels`` instead. ├ available_from datetime The first date time at which this variation can be bought (or ``null``). ├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop @@ -276,6 +282,8 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "default_price": "23.00", "original_price": null, @@ -340,6 +348,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -362,6 +372,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -420,6 +432,8 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "default_price": "23.00", "original_price": null, @@ -485,6 +499,8 @@ Endpoints "require_membership": false, "require_membership_types": [], "description": null, + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -506,6 +522,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -545,7 +563,8 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", - "sales_channels": ["web"], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "default_price": "23.00", "original_price": null, "category": null, @@ -608,7 +627,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], - "sales_channels": ["web"], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", "available_until": null, @@ -630,7 +650,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], - "sales_channels": ["web"], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", "available_until": null, @@ -657,6 +678,8 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "default_price": "23.00", "original_price": null, @@ -721,6 +744,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -743,6 +768,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -801,6 +828,8 @@ Endpoints "id": 1, "name": {"en": "Ticket"}, "internal_name": "", + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "default_price": "25.00", "original_price": null, @@ -865,6 +894,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", @@ -887,6 +918,8 @@ Endpoints "require_approval": false, "require_membership": false, "require_membership_types": [], + "all_sales_channels": false, + "limit_sales_channels": ["web"], "sales_channels": ["web"], "available_from": null, "available_from_mode": "hide", diff --git a/doc/api/resources/saleschannels.rst b/doc/api/resources/saleschannels.rst new file mode 100644 index 0000000000..a247dbb243 --- /dev/null +++ b/doc/api/resources/saleschannels.rst @@ -0,0 +1,219 @@ +Sales channels +============== + +Resource description +-------------------- + +The sales channel resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +Field Type Description +===================================== ========================== ======================================================= +identifier string Internal ID of the sales channel. For sales channel types + that allow only one instance, this is the same as ``type``. + For sales channel types that allow multiple instances, this + is always prefixed with ``type.``. +label multi-lingual string Human-readable name of the sales channel +type string Type of the sales channel. Only channels with type ``api`` + can currently be created through the API. +position integer Position for sorting lists of sales channels +===================================== ========================== ======================================================= + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/saleschannels/ + + Returns a list of all sales channels within a given organizer. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/saleschannels/ 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": [ + { + "identifier": "web", + "name": { + "en": "Online shop" + }, + "type": "web", + "position": 0 + } + ] + } + + :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)/saleschannels/(identifier)/ + + Returns information on one sales channel, identified by its identifier. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/saleschannels/web/ 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 + + { + "identifier": "web", + "name": { + "en": "Online shop" + }, + "type": "web", + "position": 0 + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param identifier: The ``identifier`` field of the sales channel 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)/saleschannels/ + + Creates a sales channel + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/saleschannels/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + + { + "identifier": "api.custom", + "name": { + "en": "Custom integration" + }, + "type": "api", + "position": 2 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 201 Created + Vary: Accept + Content-Type: application/json + + { + "identifier": "api.custom", + "name": { + "en": "Custom integration" + }, + "type": "api", + "position": 2 + } + + :param organizer: The ``slug`` field of the organizer to create a sales channel for + :statuscode 201: no error + :statuscode 400: The sales channel 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)/saleschannels/(identifier)/ + + Update a sales channel. 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 ``identifier`` and ``type`` fields. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/saleschannels/web/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "position": 5 + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "identifier": "web", + "name": { + "en": "Online shop" + }, + "type": "web", + "position": 5 + } + + :param organizer: The ``slug`` field of the organizer to modify + :param identifier: The ``identifier`` field of the sales channel to modify + :statuscode 200: no error + :statuscode 400: The sales channel 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. + +.. http:delete:: /api/v1/organizers/(organizer)/saleschannels/(id)/ + + Delete a sales channel. You can not delete sales channels which have already been used or which are integral parts + of the system. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/saleschannels/api.custom/ 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 identifier: The ``identifier`` field of the sales channel 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 sales channel is currently in use. diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index 4eb26229c8..3ea5dc29cb 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -12,7 +12,7 @@ Core .. automodule:: pretix.base.signals :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification, - item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter, + item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter, register_ticket_secret_generators, gift_card_transaction_display, register_text_placeholders, register_mail_placeholders diff --git a/src/pretix/api/serializers/__init__.py b/src/pretix/api/serializers/__init__.py index 0aa212c996..b35fb1eb2f 100644 --- a/src/pretix/api/serializers/__init__.py +++ b/src/pretix/api/serializers/__init__.py @@ -21,6 +21,7 @@ # import json +from django.core.exceptions import ValidationError from rest_framework import serializers @@ -61,3 +62,57 @@ class CompatibleJSONField(serializers.JSONField): if value: return json.loads(value) return value + + +class SalesChannelMigrationMixin: + """ + Translates between the old field "sales_channels" and the new field combo "all_sales_channels"/"limit_sales_channels". + """ + + @property + def organizer(self): + if "organizer" in self.context: + return self.context["organizer"] + elif "event" in self.context: + return self.context["event"].organizer + else: + raise ValueError("organizer not in context") + + def to_internal_value(self, data): + if "sales_channels" in data: + all_channels = { + s.identifier for s in + self.organizer.sales_channels.all() + } + + if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels: + raise ValidationError( + "If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " + "the list of all sales channels." + ) + + if data.get("limit_sales_channels") and set(data["sales_channels"]) != set(data["limit_sales_channels"]): + raise ValidationError( + "If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to " + "the same list." + ) + + if data["sales_channels"] == all_channels: + data["all_sales_channels"] = True + data["limit_sales_channels"] = [] + else: + data["all_sales_channels"] = False + data["limit_sales_channels"] = data["sales_channels"] + del data["sales_channels"] + return super().to_internal_value(data) + + def to_representation(self, value): + value = super().to_representation(value) + if value.get("all_sales_channels"): + value["sales_channels"] = sorted([ + s.identifier for s in + self.organizer.sales_channels.all() + ]) + else: + value["sales_channels"] = value["limit_sales_channels"] + return value diff --git a/src/pretix/api/serializers/cart.py b/src/pretix/api/serializers/cart.py index 5e900c47ab..6c7f457c24 100644 --- a/src/pretix/api/serializers/cart.py +++ b/src/pretix/api/serializers/cart.py @@ -33,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.order import ( AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer, ) -from pretix.base.models import Seat, Voucher +from pretix.base.models import SalesChannel, Seat, Voucher from pretix.base.models.orders import CartPosition @@ -212,7 +212,11 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer): addons = BaseCartPositionCreateSerializer(many=True, required=False) bundled = BaseCartPositionCreateSerializer(many=True, required=False) seat = serializers.CharField(required=False, allow_null=True) - sales_channel = serializers.CharField(required=False, default='sales_channel') + sales_channel = serializers.SlugRelatedField( + slug_field='identifier', + queryset=SalesChannel.objects.none(), + required=False, + ) voucher = serializers.CharField(required=False, allow_null=True) class Meta: @@ -221,6 +225,10 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer): 'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher' ) + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all() + def validate_cart_id(self, cid): if cid and not cid.endswith('@api'): raise ValidationError('Cart ID should end in @api or be empty.') diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index c40bb21d78..fea029dad1 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -25,14 +25,20 @@ from rest_framework.exceptions import ValidationError from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer -from pretix.base.channels import get_all_sales_channels from pretix.base.media import MEDIA_TYPES -from pretix.base.models import Checkin, CheckinList +from pretix.base.models import Checkin, CheckinList, SalesChannel class CheckinListSerializer(I18nAwareModelSerializer): checkin_count = serializers.IntegerField(read_only=True) position_count = serializers.IntegerField(read_only=True) + auto_checkin_sales_channels = serializers.SlugRelatedField( + slug_field="identifier", + queryset=SalesChannel.objects.none(), + required=False, + allow_empty=True, + many=True, + ) class Meta: model = CheckinList @@ -43,6 +49,8 @@ class CheckinListSerializer(I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + self.fields['auto_checkin_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() + if 'subevent' in self.context['request'].query_params.getlist('expand'): self.fields['subevent'] = SubEventSerializer(read_only=True) @@ -72,10 +80,6 @@ class CheckinListSerializer(I18nAwareModelSerializer): if full_data.get('subevent'): raise ValidationError(_('The subevent does not belong to this event.')) - for channel in full_data.get('auto_checkin_sales_channels') or []: - if channel not in get_all_sales_channels(): - raise ValidationError(_('Unknown sales channel.')) - CheckinList.validate_rules(data.get('rules')) return data diff --git a/src/pretix/api/serializers/discount.py b/src/pretix/api/serializers/discount.py index 7e886ed644..1f632c1b73 100644 --- a/src/pretix/api/serializers/discount.py +++ b/src/pretix/api/serializers/discount.py @@ -19,18 +19,27 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # +from rest_framework import serializers +from pretix.api.serializers import SalesChannelMigrationMixin from pretix.api.serializers.i18n import I18nAwareModelSerializer -from pretix.base.models import Discount +from pretix.base.models import Discount, SalesChannel -class DiscountSerializer(I18nAwareModelSerializer): +class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): + limit_sales_channels = serializers.SlugRelatedField( + slug_field="identifier", + queryset=SalesChannel.objects.none(), + required=False, + allow_empty=True, + many=True, + ) class Meta: model = Discount - fields = ('id', 'active', 'internal_name', 'position', 'sales_channels', 'available_from', - 'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products', - 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value', + fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels', + 'available_from', 'available_until', 'subevent_mode', 'condition_all_products', + 'condition_limit_products', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value', 'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches', 'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons', 'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted') @@ -39,6 +48,7 @@ class DiscountSerializer(I18nAwareModelSerializer): super().__init__(*args, **kwargs) self.fields['condition_limit_products'].queryset = self.context['event'].items.all() self.fields['benefit_limit_products'].queryset = self.context['event'].items.all() + self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index ad7f6822c9..d7b35417d0 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -46,10 +46,14 @@ from rest_framework import serializers from rest_framework.fields import ChoiceField, Field from rest_framework.relations import SlugRelatedField -from pretix.api.serializers import CompatibleJSONField +from pretix.api.serializers import ( + CompatibleJSONField, SalesChannelMigrationMixin, +) from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.settings import SettingsSerializer -from pretix.base.models import Device, Event, TaxRule, TeamAPIToken +from pretix.base.models import ( + Device, Event, SalesChannel, TaxRule, TeamAPIToken, +) from pretix.base.models.event import SubEvent from pretix.base.models.items import ( ItemMetaProperty, SubEventItem, SubEventItemVariation, @@ -161,7 +165,7 @@ class ValidKeysField(Field): } -class EventSerializer(I18nAwareModelSerializer): +class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): meta_data = MetaDataField(required=False, source='*') item_meta_properties = MetaPropertyField(required=False, source='*') plugins = PluginsField(required=False, source='*') @@ -170,6 +174,13 @@ class EventSerializer(I18nAwareModelSerializer): valid_keys = ValidKeysField(source='*', read_only=True) best_availability_state = serializers.IntegerField(allow_null=True, read_only=True) public_url = serializers.SerializerMethodField('get_event_url', read_only=True) + limit_sales_channels = serializers.SlugRelatedField( + slug_field="identifier", + queryset=SalesChannel.objects.none(), + required=False, + allow_empty=True, + many=True, + ) def get_event_url(self, event): return build_absolute_uri(event, 'presale:event.index') @@ -180,7 +191,7 @@ class EventSerializer(I18nAwareModelSerializer): 'date_to', 'date_admission', 'is_public', 'presale_start', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys', - 'sales_channels', 'best_availability_state', 'public_url') + 'all_sales_channels', 'limit_sales_channels', 'best_availability_state', 'public_url') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -188,6 +199,7 @@ class EventSerializer(I18nAwareModelSerializer): self.fields.pop('valid_keys') if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET: self.fields.pop('best_availability_state') + self.fields['limit_sales_channels'].child_relation.queryset = self.context['organizer'].sales_channels.all() def validate(self, data): data = super().validate(data) diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 31c217acef..596f86f960 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -42,19 +42,27 @@ from django.utils.functional import cached_property, lazy from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from pretix.api.serializers import SalesChannelMigrationMixin from pretix.api.serializers.event import MetaDataField from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.base.models import ( Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation, - ItemVariationMetaValue, Question, QuestionOption, Quota, + ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel, ) -class InlineItemVariationSerializer(I18nAwareModelSerializer): +class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13, coerce_to_string=True) meta_data = MetaDataField(required=False, source='*') + limit_sales_channels = serializers.SlugRelatedField( + slug_field="identifier", + queryset=SalesChannel.objects.none(), + required=False, + allow_empty=True, + many=True, + ) class Meta: model = ItemVariation @@ -63,11 +71,12 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): 'require_membership', 'require_membership_types', 'require_membership_hidden', 'checkin_attention', 'checkin_text', 'available_from', 'available_from_mode', 'available_until', 'available_until_mode', - 'sales_channels', 'hide_without_voucher', 'meta_data') + 'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet) + self.fields['limit_sales_channels'].child_relation.queryset = lazy(lambda: self.context['event'].organizer.sales_channels.all(), QuerySet) def validate_meta_data(self, value): for key in value['meta_data'].keys(): @@ -76,10 +85,17 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): return value -class ItemVariationSerializer(I18nAwareModelSerializer): +class ItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13, coerce_to_string=True) meta_data = MetaDataField(required=False, source='*') + limit_sales_channels = serializers.SlugRelatedField( + slug_field="identifier", + queryset=SalesChannel.objects.none(), + required=False, + allow_empty=True, + many=True, + ) class Meta: model = ItemVariation @@ -88,11 +104,12 @@ class ItemVariationSerializer(I18nAwareModelSerializer): 'require_membership', 'require_membership_types', 'require_membership_hidden', 'checkin_attention', 'checkin_text', 'available_from', 'available_from_mode', 'available_until', 'available_until_mode', - 'sales_channels', 'hide_without_voucher', 'meta_data') + 'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() + self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() @transaction.atomic def create(self, validated_data): @@ -223,7 +240,7 @@ class ItemTaxRateField(serializers.Field): return str(Decimal('0.00')) -class ItemSerializer(I18nAwareModelSerializer): +class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): addons = InlineItemAddOnSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False) @@ -232,11 +249,18 @@ class ItemSerializer(I18nAwareModelSerializer): picture = UploadedFileField(required=False, allow_null=True, allowed_types=( 'image/png', 'image/jpeg', 'image/gif' ), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE) + limit_sales_channels = serializers.SlugRelatedField( + slug_field="identifier", + queryset=SalesChannel.objects.none(), + required=False, + allow_empty=True, + many=True, + ) class Meta: model = Item - fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', - 'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission', + fields = ('id', 'category', 'name', 'internal_name', 'active', 'all_sales_channels', 'limit_sales_channels', + 'description', 'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission', 'personalized', 'position', 'picture', 'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', @@ -259,6 +283,7 @@ class ItemSerializer(I18nAwareModelSerializer): if not self.read_only: self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all() + self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all() def validate(self, data): data = super().validate(data) @@ -335,7 +360,10 @@ class ItemSerializer(I18nAwareModelSerializer): meta_data = validated_data.pop('meta_data', None) picture = validated_data.pop('picture', None) require_membership_types = validated_data.pop('require_membership_types', []) + limit_sales_channels = validated_data.pop('limit_sales_channels', []) item = Item.objects.create(**validated_data) + if limit_sales_channels: + item.limit_sales_channels.add(*limit_sales_channels) if picture: item.picture.save(os.path.basename(picture.name), picture) if require_membership_types: diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 43071a111e..410d7b3a24 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -46,13 +46,12 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.item import ( InlineItemVariationSerializer, ItemSerializer, QuestionSerializer, ) -from pretix.base.channels import get_all_sales_channels from pretix.base.decimal import round_decimal from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, QuestionAnswer, - ReusableMedium, Seat, SubEvent, TaxRule, Voucher, + ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher, ) from pretix.base.models.orders import ( BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, @@ -714,6 +713,11 @@ class OrderSerializer(I18nAwareModelSerializer): payment_provider = OrderPaymentTypeField(source='*', read_only=True) url = OrderURLField(source='*', read_only=True) customer = serializers.SlugRelatedField(slug_field='identifier', read_only=True) + sales_channel = serializers.SlugRelatedField( + slug_field='identifier', + queryset=SalesChannel.objects.none(), + required=False, + ) class Meta: model = Order @@ -732,6 +736,10 @@ class OrderSerializer(I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + if "organizer" in self.context: + self.fields["sales_channel"].queryset = self.context["organizer"].sales_channels.all() + else: + self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all() if not self.context['pdf_data']: self.fields['positions'].child.fields.pop('pdf_data', None) @@ -1033,12 +1041,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer): require_approval = serializers.BooleanField(default=False, required=False) simulate = serializers.BooleanField(default=False, required=False) customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False) + sales_channel = serializers.SlugRelatedField( + slug_field='identifier', + queryset=SalesChannel.objects.none(), + required=False, + ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all() self.fields['customer'].queryset = self.context['event'].organizer.customers.all() self.fields['expires'].required = False + self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all() class Meta: model = Order @@ -1059,11 +1073,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer): raise ValidationError('Expiration date must be in the future.') return expires - def validate_sales_channel(self, channel): - if channel not in get_all_sales_channels(): - raise ValidationError('Unknown sales channel.') - return channel - def validate_code(self, code): if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists(): raise ValidationError( @@ -1125,20 +1134,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer): raise ValidationError(errs) return data - def validate_testmode(self, testmode): - if 'sales_channel' in self.initial_data: - try: - sales_channel = get_all_sales_channels()[self.initial_data['sales_channel']] - - if testmode and not sales_channel.testmode_supported: - raise ValidationError('This sales channel does not provide support for test mode.') - except KeyError: - # We do not need to raise a ValidationError here, since there is another check to validate the - # sales_channel - pass - - return testmode - def create(self, validated_data): fees_data = validated_data.pop('fees') if 'fees' in validated_data else [] positions_data = validated_data.pop('positions') if 'positions' in validated_data else [] @@ -1147,9 +1142,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer): payment_date = validated_data.pop('payment_date', now()) force = validated_data.pop('force', False) simulate = validated_data.pop('simulate', False) + + if not validated_data.get("sales_channel"): + validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web") + + if validated_data.get("testmode") and not validated_data["sales_channel"].type_instance.testmode_supported: + raise ValidationError({"testmode": ["This sales channel does not provide support for test mode."]}) + self._send_mail = validated_data.pop('send_email', False) if self._send_mail is None: - self._send_mail = validated_data.get('sales_channel') in self.context['event'].settings.mail_sales_channel_placed_paid + self._send_mail = validated_data["sales_channel"].identifier in self.context['event'].settings.mail_sales_channel_placed_paid if 'invoice_address' in validated_data: iadata = validated_data.pop('invoice_address') @@ -1309,7 +1311,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer): errs[i]['seat'] = ['The specified seat does not exist.'] else: seat_usage[seat] += 1 - if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat_usage[seat] > 1: + sales_channel_id = validated_data['sales_channel'].identifier + if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=sales_channel_id)) or seat_usage[seat] > 1: errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)] elif seated: errs[i]['seat'] = ['The specified product requires to choose a seat.'] @@ -1368,6 +1371,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): if validated_data.get('locale', None) is None: validated_data['locale'] = self.context['event'].settings.locale + order = Order(event=self.context['event'], **validated_data) if not validated_data.get('expires'): order.set_expires(subevents=[p.get('subevent') for p in positions_data]) diff --git a/src/pretix/api/serializers/organizer.py b/src/pretix/api/serializers/organizer.py index 967d4452e9..cb47fed208 100644 --- a/src/pretix/api/serializers/organizer.py +++ b/src/pretix/api/serializers/organizer.py @@ -38,7 +38,7 @@ from pretix.base.i18n import get_language_without_region from pretix.base.models import ( Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction, Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, - SeatingPlan, Team, TeamAPIToken, TeamInvite, User, + SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User, ) from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.services.mail import SendMailException, mail @@ -165,6 +165,36 @@ class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField): self.fail('incorrect_type', data_type=type(data).__name__) +class SalesChannelSerializer(I18nAwareModelSerializer): + type = serializers.CharField(default="api") + + class Meta: + model = SalesChannel + fields = ('identifier', 'type', 'label', 'position') + + def validate_type(self, value): + if (not self.instance or not self.instance.pk) and value != "api": + raise ValidationError( + "You can currently only create channels of type 'api' through the API." + ) + if value and self.instance and self.instance.pk and self.instance.type != value: + raise ValidationError( + "You cannot change the type of a sales channel." + ) + return value + + def validate_identifier(self, value): + if (not self.instance or not self.instance.pk) and not value.startswith("api."): + raise ValidationError( + "Your identifier needs to start with 'api.'." + ) + if value and self.instance and self.instance.pk and self.instance.identifier != value: + raise ValidationError( + "You cannot change the identifier of a sales channel." + ) + return value + + class GiftCardSerializer(I18nAwareModelSerializer): value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00')) owner_ticket = FlexibleTicketRelatedField(required=False, allow_null=True, queryset=OrderPosition.all.none()) diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index c8ba633a68..78d9cb68a8 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -56,6 +56,7 @@ orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) orga_router.register(r'giftcards', organizer.GiftCardViewSet) orga_router.register(r'customers', organizer.CustomerViewSet) +orga_router.register(r'saleschannels', organizer.SalesChannelViewSet) orga_router.register(r'memberships', organizer.MembershipViewSet) orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet) orga_router.register(r'reusablemedia', media.ReusableMediaViewSet) diff --git a/src/pretix/api/views/cart.py b/src/pretix/api/views/cart.py index 342ad0a1d7..8339822dae 100644 --- a/src/pretix/api/views/cart.py +++ b/src/pretix/api/views/cart.py @@ -211,8 +211,12 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly if validated_data.get('seat'): # Assumption: Add-ons currently can't have seats, thus we only need to check the main product + if validated_data.get('sales_channel'): + sales_channel_id = validated_data.get('sales_channel').identifier + else: + sales_channel_id = "web" if not validated_data['seat'].is_available( - sales_channel=validated_data.get('sales_channel', 'web'), + sales_channel=sales_channel_id, distance_ignore_cart_id=validated_data['cart_id'], ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None, ): diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 861459018f..62a7f6baf3 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -115,7 +115,7 @@ class CheckinListViewSet(viewsets.ModelViewSet): if 'subevent' in self.request.query_params.getlist('expand'): qs = qs.prefetch_related( 'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set', - 'subevent__seat_category_mappings', 'subevent__meta_values' + 'subevent__seat_category_mappings', 'subevent__meta_values', 'auto_checkin_sales_channels' ) return qs diff --git a/src/pretix/api/views/discount.py b/src/pretix/api/views/discount.py index 71be2a2799..d3545d4be2 100644 --- a/src/pretix/api/views/discount.py +++ b/src/pretix/api/views/discount.py @@ -60,7 +60,9 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet): write_permission = 'can_change_items' def get_queryset(self): - return self.request.event.discounts.all() + return self.request.event.discounts.prefetch_related( + 'limit_sales_channels', + ) def perform_create(self, serializer): serializer.save(event=self.request.event) diff --git a/src/pretix/api/views/event.py b/src/pretix/api/views/event.py index f98122dc25..8ac48febb0 100644 --- a/src/pretix/api/views/event.py +++ b/src/pretix/api/views/event.py @@ -113,7 +113,10 @@ with scopes_disabled(): return queryset.exclude(expr) def sales_channel_qs(self, queryset, name, value): - return queryset.filter(sales_channels__contains=value) + return queryset.filter( + Q(all_sales_channels=True) | + Q(limit_sales_channels__identifier=value) + ) def search_qs(self, queryset, name, value): return queryset.filter( @@ -135,6 +138,12 @@ class EventViewSet(viewsets.ModelViewSet): ordering_fields = ('date_from', 'slug') filterset_class = EventFilter + def get_serializer_context(self): + return { + **super().get_serializer_context(), + "organizer": self.request.organizer, + } + def get_copy_from_queryset(self): if isinstance(self.request.auth, (TeamAPIToken, Device)): return self.request.auth.get_events_with_any_permission() @@ -160,6 +169,7 @@ class EventViewSet(viewsets.ModelViewSet): 'meta_values', 'meta_values__property', 'item_meta_properties', + 'limit_sales_channels', Prefetch( 'seat_category_mappings', to_attr='_seat_category_mappings', @@ -268,8 +278,6 @@ class EventViewSet(viewsets.ModelViewSet): new_event.is_public = serializer.validated_data['is_public'] if 'testmode' in serializer.validated_data: new_event.testmode = serializer.validated_data['testmode'] - if 'sales_channels' in serializer.validated_data: - new_event.sales_channels = serializer.validated_data['sales_channels'] if 'has_subevents' in serializer.validated_data: new_event.has_subevents = serializer.validated_data['has_subevents'] if 'date_admission' in serializer.validated_data: @@ -277,6 +285,10 @@ class EventViewSet(viewsets.ModelViewSet): new_event.save() if 'timezone' in serializer.validated_data: new_event.settings.timezone = serializer.validated_data['timezone'] + + if 'all_sales_channels' in serializer.validated_data and 'sales_channels' in serializer.validated_data: + new_event.all_sales_channels = serializer.validated_data['all_sales_channels'] + new_event.limit_sales_channels.set(serializer.validated_data['limit_sales_channels']) else: serializer.instance.set_defaults() @@ -379,7 +391,10 @@ with scopes_disabled(): return queryset.exclude(expr) def sales_channel_qs(self, queryset, name, value): - return queryset.filter(event__sales_channels__contains=value) + return queryset.filter( + Q(event__all_sales_channels=True) | + Q(event__limit_sales_channels__identifier=value) + ) def search_qs(self, queryset, name, value): return queryset.filter( diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 2d24f4065c..f904499b5e 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -87,6 +87,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): 'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property', 'variations__meta_values', 'variations__meta_values__property', 'require_membership_types', 'variations__require_membership_types', + 'limit_sales_channels', 'variations__limit_sales_channels', ).all() def perform_create(self, serializer): @@ -152,7 +153,8 @@ class ItemVariationViewSet(viewsets.ModelViewSet): return self.item.variations.all().prefetch_related( 'meta_values', 'meta_values__property', - 'require_membership_types' + 'require_membership_types', + 'limit_sales_channels', ) def get_serializer_context(self): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 5d47f2fc35..606f8cdd48 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -229,7 +229,7 @@ class OrderViewSetMixin: if 'customer' not in self.request.GET.getlist('exclude'): qs = qs.select_related('customer') - qs = qs.prefetch_related(self._positions_prefetch(self.request)) + qs = qs.select_related('sales_channel').prefetch_related(self._positions_prefetch(self.request)) return qs def _positions_prefetch(self, request): @@ -316,6 +316,11 @@ class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet): else: raise PermissionDenied() + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): permission = 'can_view_orders' diff --git a/src/pretix/api/views/organizer.py b/src/pretix/api/views/organizer.py index a83ffcd434..5f284ff6fe 100644 --- a/src/pretix/api/views/organizer.py +++ b/src/pretix/api/views/organizer.py @@ -43,13 +43,13 @@ from pretix.api.serializers.organizer import ( CustomerCreateSerializer, CustomerSerializer, DeviceSerializer, GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer, MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer, - SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer, - TeamMemberSerializer, TeamSerializer, + SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer, + TeamInviteSerializer, TeamMemberSerializer, TeamSerializer, ) from pretix.base.models import ( Customer, Device, GiftCard, GiftCardTransaction, Membership, - MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, - User, + MembershipType, Organizer, SalesChannel, SeatingPlan, Team, TeamAPIToken, + TeamInvite, User, ) from pretix.helpers import OF_SELF from pretix.helpers.dicts import merge_dicts @@ -675,3 +675,68 @@ class MembershipViewSet(viewsets.ModelViewSet): data=self.request.data, ) return inst + + +with scopes_disabled(): + class SalesChannelFilter(FilterSet): + class Meta: + model = SalesChannel + fields = ['type', 'identifier'] + + +class SalesChannelViewSet(viewsets.ModelViewSet): + serializer_class = SalesChannelSerializer + queryset = SalesChannel.objects.none() + permission = 'can_change_organizer_settings' + write_permission = 'can_change_organizer_settings' + filter_backends = (DjangoFilterBackend,) + filterset_class = SalesChannelFilter + lookup_field = 'identifier' + lookup_url_kwarg = 'identifier' + lookup_value_regex = r"[a-zA-Z0-9.\-_]+" + + def get_queryset(self): + return self.request.organizer.sales_channels.all() + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['organizer'] = self.request.organizer + return ctx + + @transaction.atomic() + def perform_create(self, serializer): + inst = serializer.save( + organizer=self.request.organizer, + type="api" + ) + inst.log_action( + 'pretix.saleschannel.created', + user=self.request.user, + auth=self.request.auth, + data=merge_dicts(self.request.data, {'id': inst.pk}) + ) + + @transaction.atomic() + def perform_update(self, serializer): + inst = serializer.save( + type=serializer.instance.type, + identifier=serializer.instance.identifier, + ) + inst.log_action( + 'pretix.sales_channel.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data, + ) + return inst + + def perform_destroy(self, instance): + if not instance.allow_delete(): + raise PermissionDenied("Can only be deleted if unused.") + instance.log_action( + 'pretix.saleschannel.deleted', + user=self.request.user, + auth=self.request.auth, + data={'id': instance.pk} + ) + instance.delete() diff --git a/src/pretix/base/channels.py b/src/pretix/base/channels.py index c4f200496b..c9ad7cf385 100644 --- a/src/pretix/base/channels.py +++ b/src/pretix/base/channels.py @@ -20,56 +20,83 @@ # . # import logging +import warnings from collections import OrderedDict from django.dispatch import receiver from django.utils.translation import gettext_lazy as _ -from pretix.base.signals import register_sales_channels +from pretix.base.signals import ( + register_sales_channel_types, register_sales_channels, +) logger = logging.getLogger(__name__) -_ALL_CHANNELS = None +_ALL_CHANNEL_TYPES = None -class SalesChannel: +class SalesChannelType: def __repr__(self): - return ''.format(self.identifier) + return ''.format(self.identifier) @property def identifier(self) -> str: """ - The internal identifier of this sales channel. + The internal identifier of this sales channel type. """ raise NotImplementedError() # NOQA @property def verbose_name(self) -> str: """ - A human-readable name of this sales channel. + A human-readable name of this sales channel type. """ raise NotImplementedError() # NOQA + @property + def description(self) -> str: + """ + A human-readable description of this sales channel type. + """ + return "" + @property def icon(self) -> str: """ - The name of a Font Awesome icon to represent this channel + This can be: + + - The name of a Font Awesome icon to represent this channel type. + - The name of a SVG icon file that is resolvable through the static file system. We recommend to design for a size of 18x14 pixels. """ return "circle" + @property + def default_created(self) -> bool: + """ + Indication, if a sales channel of this type should automatically be created for every organizer + """ + return True + + @property + def multiple_allowed(self) -> bool: + """ + Indication, if multiple sales channels of this type may exist in the same organizer + """ + return False + @property def testmode_supported(self) -> bool: """ - Indication, if a saleschannels supports test mode orders + Indication, if a sales channel of this type supports test mode orders """ return True @property def payment_restrictions_supported(self) -> bool: """ - If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel. + If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel type. - Example: pretixPOS provides its own sales channel, ignores the configured payment providers completely and - handles payments locally. Therefor, this property should be set to ``False`` for the pretixPOS sales channel as + Example: pretixPOS provides its own sales channel type, ignores the configured payment providers completely and + handles payments locally. Therefore, this property should be set to ``False`` for the pretixPOS sales channel as the event organizer cannot restrict the usage of any payment provider through the backend. """ return True @@ -77,8 +104,8 @@ class SalesChannel: @property def unlimited_items_per_order(self) -> bool: """ - If this property is ``True``, purchases made using this sales channel are not limited to the maximum amount of - items defined in the event settings. + If this property is ``True``, purchases made using sales channels of this type are not limited to the maximum + amount of items defined in the event settings. """ return False @@ -96,34 +123,67 @@ class SalesChannel: """ return True + @property + def required_event_plugin(self) -> str: + """ + Name of an event plugin that is required for this sales channel to be useful. Defaults to ``None``. + """ + return -def get_all_sales_channels(): - global _ALL_CHANNELS - if _ALL_CHANNELS: - return _ALL_CHANNELS +def get_all_sales_channel_types(): + from pretix.base.signals import register_sales_channel_types + global _ALL_CHANNEL_TYPES + + if _ALL_CHANNEL_TYPES: + return _ALL_CHANNEL_TYPES channels = [] - for recv, ret in register_sales_channels.send(None): + for recv, ret in register_sales_channel_types.send(None): + if isinstance(ret, (list, tuple)): + channels += ret + else: + channels.append(ret) + for recv, ret in register_sales_channels.send(None): # todo: remove me if isinstance(ret, (list, tuple)): channels += ret else: channels.append(ret) channels.sort(key=lambda c: c.identifier) - _ALL_CHANNELS = OrderedDict([(c.identifier, c) for c in channels]) - if 'web' in _ALL_CHANNELS: - _ALL_CHANNELS.move_to_end('web', last=False) - return _ALL_CHANNELS + _ALL_CHANNEL_TYPES = OrderedDict([(c.identifier, c) for c in channels]) + if 'web' in _ALL_CHANNEL_TYPES: + _ALL_CHANNEL_TYPES.move_to_end('web', last=False) + return _ALL_CHANNEL_TYPES -class WebshopSalesChannel(SalesChannel): +def get_all_sales_channels(): + # TODO: remove me + warnings.warn('Using get_all_sales_channels() is no longer appropriate, use get_al_sales_channel_types() instead.', + DeprecationWarning, stacklevel=2) + return get_all_sales_channel_types() + + +class WebshopSalesChannelType(SalesChannelType): identifier = "web" verbose_name = _('Online shop') icon = "globe" -@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels") +class ApiSalesChannelType(SalesChannelType): + identifier = "api" + verbose_name = _('API') + description = _('API sales channels come with no built-in functionality, but may be used for custom integrations.') + icon = "exchange" + default_created = False + multiple_allowed = True + + +SalesChannel = SalesChannelType # TODO: remove me + + +@receiver(register_sales_channel_types, dispatch_uid="base_register_default_sales_channel_types") def base_sales_channels(sender, **kwargs): return ( - WebshopSalesChannel(), + WebshopSalesChannelType(), + ApiSalesChannelType(), ) diff --git a/src/pretix/base/exporters/items.py b/src/pretix/base/exporters/items.py index cbf891714b..fdff516e43 100644 --- a/src/pretix/base/exporters/items.py +++ b/src/pretix/base/exporters/items.py @@ -27,7 +27,6 @@ from openpyxl.styles import Alignment from openpyxl.utils import get_column_letter from ...helpers.safe_openpyxl import SafeCell -from ..channels import get_all_sales_channels from ..exporter import ListExporter from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue from ..signals import register_data_exporters @@ -53,7 +52,7 @@ class ItemDataExporter(ListExporter): def iterate_list(self, form_data): locales = self.event.settings.locales - scs = get_all_sales_channels() + scs = self.organizer.sales_channels.all() header = [ _("Product ID"), _("Variation ID"), @@ -141,9 +140,15 @@ class ItemDataExporter(ListExporter): row.append(i.name.localize(l)) for l in locales: row.append(v.value.localize(l)) + + sales_channels = list(scs) + if not i.all_sales_channels: + sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in i.limit_sales_channels.all())] + if not v.all_sales_channels: + sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in v.limit_sales_channels.all())] row += [ _("Yes") if i.active and v.active else "", - ", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels and s in v.sales_channels]), + ", ".join([str(sn.label) for sn in sales_channels]), v.default_price or i.default_price, _("Yes") if i.free_price else "", str(i.tax_rule) if i.tax_rule else "", @@ -186,9 +191,12 @@ class ItemDataExporter(ListExporter): row.append(i.name.localize(l)) for l in locales: row.append("") + sales_channels = list(scs) + if not i.all_sales_channels: + sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in i.limit_sales_channels.all())] row += [ _("Yes") if i.active else "", - ", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels]), + ", ".join([str(sn.label) for sn in sales_channels]), i.default_price, _("Yes") if i.free_price else "", str(i.tax_rule) if i.tax_rule else "", diff --git a/src/pretix/base/migrations/0159_mails_by_sales_channel.py b/src/pretix/base/migrations/0159_mails_by_sales_channel.py index 2c18e7c1b9..37949c6902 100644 --- a/src/pretix/base/migrations/0159_mails_by_sales_channel.py +++ b/src/pretix/base/migrations/0159_mails_by_sales_channel.py @@ -2,7 +2,7 @@ from django.db import migrations -from pretix.base.channels import get_all_sales_channels +from pretix.base.channels import get_all_sales_channel_types def set_sales_channels(apps, schema_editor): @@ -11,7 +11,7 @@ def set_sales_channels(apps, schema_editor): # Therefore, for existing events, we enable all sales channels Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore') Event = apps.get_model('pretixbase', 'Event') - all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channels()) + "]" + all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channel_types()) + "]" batch_size = 1000 Event_SettingsStore.objects.bulk_create([ Event_SettingsStore( diff --git a/src/pretix/base/migrations/0172_event_sales_channels.py b/src/pretix/base/migrations/0172_event_sales_channels.py index f04b3abf35..a55ff7c9aa 100644 --- a/src/pretix/base/migrations/0172_event_sales_channels.py +++ b/src/pretix/base/migrations/0172_event_sales_channels.py @@ -3,7 +3,7 @@ from django.db import migrations import pretix.base.models.fields -from pretix.base.channels import get_all_sales_channels +from pretix.base.channels import get_all_sales_channel_types class Migration(migrations.Migration): @@ -15,6 +15,6 @@ class Migration(migrations.Migration): migrations.AddField( model_name='event', name='sales_channels', - field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channels().keys())), + field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channel_types().keys())), ), ] diff --git a/src/pretix/base/migrations/0265_saleschannel_and_more.py b/src/pretix/base/migrations/0265_saleschannel_and_more.py new file mode 100644 index 0000000000..05519dca1b --- /dev/null +++ b/src/pretix/base/migrations/0265_saleschannel_and_more.py @@ -0,0 +1,110 @@ +# Generated by Django 4.2.8 on 2024-03-24 17:43 + +import django.db.models.deletion +import i18nfield.fields +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0264_order_internal_secret"), + ] + + operations = [ + migrations.CreateModel( + name="SalesChannel", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("label", i18nfield.fields.I18nCharField(max_length=200)), + ("identifier", models.CharField(max_length=200)), + ("type", models.CharField(max_length=200)), + ("position", models.PositiveIntegerField(default=0)), + ("configuration", models.JSONField(default=dict)), + ], + ), + migrations.RenameField( + model_name="checkinlist", + old_name="auto_checkin_sales_channels", + new_name="auto_checkin_sales_channel_types", + ), + migrations.AddField( + model_name="discount", + name="all_sales_channels", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="event", + name="all_sales_channels", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="item", + name="all_sales_channels", + field=models.BooleanField(default=True), + ), + migrations.AddField( + model_name="itemvariation", + name="all_sales_channels", + field=models.BooleanField(default=True), + ), + migrations.RenameField( + model_name="order", + old_name="sales_channel", + new_name="sales_channel_type", + ), + migrations.AddField( + model_name="saleschannel", + name="organizer", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="sales_channels", + to="pretixbase.organizer", + ), + ), + migrations.AddField( + model_name="discount", + name="limit_sales_channels", + field=models.ManyToManyField(to="pretixbase.saleschannel"), + ), + migrations.AddField( + model_name="event", + name="limit_sales_channels", + field=models.ManyToManyField(to="pretixbase.saleschannel"), + ), + migrations.AddField( + model_name="item", + name="limit_sales_channels", + field=models.ManyToManyField(to="pretixbase.saleschannel"), + ), + migrations.AddField( + model_name="itemvariation", + name="limit_sales_channels", + field=models.ManyToManyField(to="pretixbase.saleschannel"), + ), + migrations.AddField( + model_name="checkinlist", + name="auto_checkin_sales_channels", + field=models.ManyToManyField(to="pretixbase.saleschannel"), + ), + migrations.AddField( + model_name="order", + name="sales_channel", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.PROTECT, + to="pretixbase.saleschannel", + ), + ), + migrations.AlterUniqueTogether( + name="saleschannel", + unique_together={("organizer", "identifier")}, + ), + ] diff --git a/src/pretix/base/migrations/0266_saleschannel_migrate_data.py b/src/pretix/base/migrations/0266_saleschannel_migrate_data.py new file mode 100644 index 0000000000..214f576cf2 --- /dev/null +++ b/src/pretix/base/migrations/0266_saleschannel_migrate_data.py @@ -0,0 +1,84 @@ +# Generated by Django 4.2.8 on 2024-03-24 17:55 + +from django.db import migrations +from i18nfield.strings import LazyI18nString + +from pretix.base.channels import get_all_sales_channel_types + + +def create_sales_channels(apps, schema_editor): + channel_types = get_all_sales_channel_types() + type_to_channel = dict() + full_discount_set = set() + full_set = set() + + Organizer = apps.get_model("pretixbase", "Organizer") + for o in Organizer.objects.all().iterator(): + for i, t in enumerate(channel_types.values()): + if not t.default_created: + continue + type_to_channel[t.identifier, o.pk] = o.sales_channels.get_or_create( + type=t.identifier, + defaults=dict( + position=i, + identifier=t.identifier, + label=LazyI18nString.from_gettext(t.verbose_name), + ), + )[0] + full_set.add(t.identifier) + if t.discounts_supported: + full_discount_set.add(t.identifier) + + Event = apps.get_model("pretixbase", "Event") + for d in Event.objects.all().iterator(): + if set(d.sales_channels) != full_set: + d.all_sales_channels = False + d.save() + for s in d.sales_channels: + d.limit_sales_channels.add(type_to_channel[s, d.organizer_id]) + + Item = apps.get_model("pretixbase", "Item") + for d in Item.objects.select_related("event").iterator(): + if set(d.sales_channels) != full_set: + d.all_sales_channels = False + d.save() + for s in d.sales_channels: + d.limit_sales_channels.add(type_to_channel[s, d.event.organizer_id]) + + ItemVariation = apps.get_model("pretixbase", "ItemVariation") + for d in ItemVariation.objects.select_related("item__event").iterator(): + if set(d.sales_channels) != full_set: + d.all_sales_channels = False + d.save() + for s in d.sales_channels: + d.limit_sales_channels.add(type_to_channel[s, d.item.event.organizer_id]) + + Discount = apps.get_model("pretixbase", "Discount") + for d in Discount.objects.select_related("event").iterator(): + if set(d.sales_channels) != full_discount_set: + d.all_sales_channels = False + d.save() + for s in d.sales_channels: + d.limit_sales_channels.add(type_to_channel[s, d.event.organizer_id]) + + CheckinList = apps.get_model("pretixbase", "CheckinList") + for c in CheckinList.objects.select_related("event").iterator(): + for s in c.auto_checkin_sales_channel_types: + c.auto_checkin_sales_channels.add(type_to_channel[s, c.event.organizer_id]) + + Order = apps.get_model("pretixbase", "Order") + for (k, orgid), v in type_to_channel.items(): + Order.objects.filter(sales_channel_type=k, event__organizer_id=orgid, sales_channel__isnull=True).update( + sales_channel=v + ) + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0265_saleschannel_and_more"), + ] + + operations = [ + migrations.RunPython(create_sales_channels, migrations.RunPython.noop), + ] diff --git a/src/pretix/base/migrations/0267_remove_old_sales_channels.py b/src/pretix/base/migrations/0267_remove_old_sales_channels.py new file mode 100644 index 0000000000..545ce366cf --- /dev/null +++ b/src/pretix/base/migrations/0267_remove_old_sales_channels.py @@ -0,0 +1,46 @@ +# Generated by Django 4.2.8 on 2024-03-25 13:34 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ("pretixbase", "0266_saleschannel_migrate_data"), + ] + + operations = [ + migrations.RemoveField( + model_name="checkinlist", + name="auto_checkin_sales_channel_types", + ), + migrations.RemoveField( + model_name="discount", + name="sales_channels", + ), + migrations.RemoveField( + model_name="event", + name="sales_channels", + ), + migrations.RemoveField( + model_name="item", + name="sales_channels", + ), + migrations.RemoveField( + model_name="itemvariation", + name="sales_channels", + ), + migrations.RemoveField( + model_name="order", + name="sales_channel_type", + ), + migrations.AlterField( + model_name="order", + name="sales_channel", + field=models.ForeignKey( + on_delete=django.db.models.deletion.PROTECT, + to="pretixbase.saleschannel", + ), + ), + ] diff --git a/src/pretix/base/modelimport_orders.py b/src/pretix/base/modelimport_orders.py index 53584d3f28..0bda26fd4d 100644 --- a/src/pretix/base/modelimport_orders.py +++ b/src/pretix/base/modelimport_orders.py @@ -38,7 +38,6 @@ from i18nfield.strings import LazyI18nString from phonenumber_field.phonenumber import to_python from phonenumbers import SUPPORTED_REGIONS -from pretix.base.channels import get_all_sales_channels from pretix.base.forms.questions import guess_country from pretix.base.modelimport import ( DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin, @@ -538,18 +537,28 @@ class Expires(DatetimeColumnMixin, ImportColumn): class Saleschannel(ImportColumn): identifier = 'sales_channel' verbose_name = gettext_lazy('Sales channel') + default_value = None + initial = 'static:web' + + @cached_property + def channels(self): + return list(self.event.organizer.sales_channels.all()) def static_choices(self): return [ - (sc.identifier, sc.verbose_name) for sc in get_all_sales_channels().values() + (c.identifier, str(c.label)) for c in self.channels ] def clean(self, value, previous_values): - if not value: - value = 'web' - if value not in get_all_sales_channels(): + matches = [ + p for p in self.channels + if p.identifier == value or any((v and v == value) for v in i18n_flat(p.label)) + ] + if len(matches) == 0: raise ValidationError(_("Please enter a valid sales channel.")) - return value + if len(matches) > 1: + raise ValidationError(_("Please enter a valid sales channel.")) + return matches[0] def assign(self, value, order, position, invoice_address, **kwargs): order.sales_channel = value diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index ea5b8d0252..d3e68f6bde 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -51,7 +51,8 @@ from .orders import ( generate_secret, ) from .organizer import ( - Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, + Organizer, Organizer_SettingsStore, SalesChannel, Team, TeamAPIToken, + TeamInvite, ) from .seating import Seat, SeatCategoryMapping, SeatingPlan from .tax import TaxRule diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 0590d6b102..f69da42754 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -46,7 +46,6 @@ from django_scopes import ScopedManager, scopes_disabled from pretix.base.media import MEDIA_TYPES from pretix.base.models import LoggedModel -from pretix.base.models.fields import MultiStringField from pretix.helpers import PostgresWindowFrame @@ -100,13 +99,13 @@ class CheckinList(LoggedModel): verbose_name=_('Automatically check out everyone at'), null=True, blank=True ) - auto_checkin_sales_channels = MultiStringField( - default=[], - blank=True, + auto_checkin_sales_channels = models.ManyToManyField( + "SalesChannel", verbose_name=_('Sales channels to automatically check in'), help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through ' 'any of the selected sales channels. This option can be useful when tickets sold at the box office ' - 'are not checked again before entry and should be considered validated directly upon purchase.') + 'are not checked again before entry and should be considered validated directly upon purchase.'), + blank=True, ) rules = models.JSONField(default=dict, blank=True) diff --git a/src/pretix/base/models/discount.py b/src/pretix/base/models/discount.py index 0a2a646af9..d547de5c72 100644 --- a/src/pretix/base/models/discount.py +++ b/src/pretix/base/models/discount.py @@ -34,7 +34,6 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy from django_scopes import ScopedManager from pretix.base.decimal import round_decimal -from pretix.base.models import fields from pretix.base.models.base import LoggedModel @@ -65,10 +64,14 @@ class Discount(LoggedModel): default=0, verbose_name=_("Position") ) - sales_channels = fields.MultiStringField( - verbose_name=_('Sales channels'), - default=['web'], - blank=False, + all_sales_channels = models.BooleanField( + verbose_name=_("All supported sales channels"), + default=True, + ) + limit_sales_channels = models.ManyToManyField( + "SalesChannel", + verbose_name=_("Sales channels"), + blank=True, ) available_from = models.DateTimeField( diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 4493e2b575..0b070e7a6f 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -36,6 +36,7 @@ import logging import os import string import uuid +import warnings from collections import Counter, OrderedDict, defaultdict from datetime import datetime, time, timedelta from operator import attrgetter @@ -66,7 +67,6 @@ from django_scopes import ScopedManager, scopes_disabled from i18nfield.fields import I18nCharField, I18nTextField from pretix.base.models.base import LoggedModel -from pretix.base.models.fields import MultiStringField from pretix.base.reldate import RelativeDateWrapper from pretix.base.timemachine import time_machine_now from pretix.base.validators import EventSlugBanlistValidator @@ -307,6 +307,7 @@ class EventMixin: def annotated(cls, qs, channel='web', voucher=None): from pretix.base.models import Item, ItemVariation, Quota + assert isinstance(channel, str) sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).filter( Q(variations__isnull=True) & Q(quotas__pk=OuterRef('pk')) @@ -316,14 +317,14 @@ class EventMixin: q_variation = ( Q(active=True) - & Q(sales_channels__contains=channel) + & Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel)) & Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now())) & Q(item__active=True) & Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now())) & Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now())) & Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False)) - & Q(item__sales_channels__contains=channel) + & Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels__identifier=channel)) & Q(item__require_bundling=False) & Q(quotas__pk=OuterRef('pk')) ) @@ -467,6 +468,7 @@ class EventMixin: return best_state_found, num_tickets_found, num_tickets_possible def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False): + assert isinstance(sales_channel, str) qs_annotated = self._seats(ignore_voucher=ignore_voucher) qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False) @@ -495,10 +497,13 @@ class EventMixin: return qs.filter(q) -def default_sales_channels(): - from ..channels import get_all_sales_channels +def default_sales_channels(): # kept for legacy migration + from ..channels import get_all_sales_channel_types - return list(get_all_sales_channels().keys()) + if "PYTEST_CURRENT_TEST" not in os.environ: + warnings.warn('Method should not be used in new code.', DeprecationWarning) + + return list(get_all_sales_channel_types().keys()) @settings_hierarkey.add(parent_field='organizer', cache_namespace='event') @@ -535,8 +540,10 @@ class Event(EventMixin, LoggedModel): :type plugins: str :param has_subevents: Enable event series functionality :type has_subevents: bool - :param sales_channels: A list of sales channel identifiers, that this event is available for sale on - :type sales_channels: list + :param all_sales_channels: A flag indicating that this event is available on all channels and limit_sales_channels will be ignored. + :type all_sales_channels: bool + :param limit_sales_channels: A list of sales channel identifiers, that this event is available for sale on + :type limit_sales_channels: list """ settings_namespace = 'event' @@ -628,10 +635,14 @@ class Event(EventMixin, LoggedModel): auto_now=True, db_index=True ) - sales_channels = MultiStringField( - verbose_name=_('Restrict to specific sales channels'), - help_text=_('Only sell tickets for this event on the following sales channels.'), - default=default_sales_channels, + all_sales_channels = models.BooleanField( + verbose_name=_("Sell on all sales channels"), + default=True, + ) + limit_sales_channels = models.ManyToManyField( + "SalesChannel", + verbose_name=_("Restrict to specific sales channels"), + blank=True, ) objects = ScopedManager(organizer='organizer') @@ -805,10 +816,17 @@ class Event(EventMixin, LoggedModel): if other.date_admission: self.date_admission = self.date_from + (other.date_admission - other.date_from) self.testmode = other.testmode - self.sales_channels = other.sales_channels + self.all_sales_channels = other.all_sales_channels self.save() self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) + if not self.all_sales_channels: + self.limit_sales_channels.set( + self.organizer.sales_channels.filter( + identifier__in=other.limit_sales_channels.values_list("identifier", flat=True) + ) + ) + if not skip_meta_data: for emv in EventMetaValue.objects.filter(event=other): emv.pk = None @@ -846,12 +864,17 @@ class Event(EventMixin, LoggedModel): item_map = {} variation_map = {} - for i in Item.objects.filter(event=other).prefetch_related('variations'): + for i in Item.objects.filter(event=other).prefetch_related( + 'variations', 'limit_sales_channels', 'require_membership_types', + 'variations__limit_sales_channels', 'variations__require_membership_types', + ): vars = list(i.variations.all()) require_membership_types = list(i.require_membership_types.all()) + limit_sales_channels = list(i.limit_sales_channels.all()) item_map[i.pk] = i i.pk = None i.event = self + i._prefetched_objects_cache = {} if i.picture: i.picture.save(os.path.basename(i.picture.name), i.picture) if i.category_id: @@ -868,12 +891,23 @@ class Event(EventMixin, LoggedModel): if require_membership_types and other.organizer_id == self.organizer_id: i.require_membership_types.set(require_membership_types) + if not i.all_sales_channels: + i.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels])) + for v in vars: + require_membership_types = list(v.require_membership_types.all()) + limit_sales_channels = list(v.limit_sales_channels.all()) variation_map[v.pk] = v v.pk = None v.item = i + v._prefetched_objects_cache = {} v.save(force_insert=True) + if require_membership_types and other.organizer_id == self.organizer_id: + v.require_membership_types.set(require_membership_types) + if not v.all_sales_channels: + v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels])) + for i in self.items.filter(hidden_if_item_available__isnull=False): i.hidden_if_item_available = item_map[i.hidden_if_item_available_id] i.save() @@ -911,6 +945,7 @@ class Event(EventMixin, LoggedModel): vars = list(q.variations.all()) oldid = q.pk q.pk = None + q._prefetched_objects_cache = {} q.event = self q.closed = False q.save(force_insert=True) @@ -922,11 +957,15 @@ class Event(EventMixin, LoggedModel): q.variations.add(variation_map[v.pk]) self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q) - for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'): + for d in Discount.objects.filter(event=other).prefetch_related( + 'condition_limit_products', 'benefit_limit_products', 'limit_sales_channels' + ): c_items = list(d.condition_limit_products.all()) b_items = list(d.benefit_limit_products.all()) + limit_sales_channels = list(v.limit_sales_channels.all()) d.pk = None d.event = self + d._prefetched_objects_cache = {} d.save(force_insert=True) d.log_action('pretix.object.cloned') for i in c_items: @@ -936,12 +975,16 @@ class Event(EventMixin, LoggedModel): if i.pk in item_map: d.benefit_limit_products.add(item_map[i.pk]) + if not d.all_sales_channels: + d.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels])) + question_map = {} for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): items = list(q.items.all()) opts = list(q.options.all()) question_map[q.pk] = q q.pk = None + q._prefetched_objects_cache = {} q.event = self q.save(force_insert=True) q.log_action('pretix.object.cloned') @@ -972,10 +1015,14 @@ class Event(EventMixin, LoggedModel): _walk_rules(i) checkin_list_map = {} - for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'): + for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related( + 'limit_products', 'auto_checkin_sales_channels' + ): items = list(cl.limit_products.all()) + auto_checkin_sales_channels = list(cl.auto_checkin_sales_channels.all()) checkin_list_map[cl.pk] = cl cl.pk = None + cl._prefetched_objects_cache = {} cl.event = self rules = cl.rules _walk_rules(rules) @@ -984,6 +1031,8 @@ class Event(EventMixin, LoggedModel): cl.log_action('pretix.object.cloned') for i in items: cl.limit_products.add(item_map[i.pk]) + if auto_checkin_sales_channels: + cl.auto_checkin_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in auto_checkin_sales_channels])) if other.seating_plan: if other.seating_plan.organizer_id == self.organizer_id: diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 7007f62c65..8c3fd3f408 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -34,8 +34,10 @@ # License for the specific language governing permissions and limitations under the License. import calendar +import os import sys import uuid +import warnings from collections import Counter, OrderedDict from datetime import date, datetime, time, timedelta from decimal import Decimal, DecimalException @@ -61,7 +63,6 @@ from django_countries.fields import Country from django_scopes import ScopedManager from i18nfield.fields import I18nCharField, I18nTextField -from pretix.base.models import fields from pretix.base.models.base import LoggedModel from pretix.base.models.fields import MultiStringField from pretix.base.models.tax import TaxedPrice @@ -270,13 +271,15 @@ class SubEventItemVariation(models.Model): def filter_available(qs, channel='web', voucher=None, allow_addons=False): + assert isinstance(channel, str) q = ( # IMPORTANT: If this is updated, also update the ItemVariation query # in models/event.py: EventMixin.annotated() Q(active=True) & Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) & Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info')) - & Q(sales_channels__contains=channel) & Q(require_bundling=False) + & Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel)) + & Q(require_bundling=False) ) if not allow_addons: q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) @@ -353,8 +356,10 @@ class Item(LoggedModel): :type original_price: decimal.Decimal :param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator :type require_approval: bool - :param sales_channels: Sales channels this item is available on. - :type sales_channels: bool + :param all_sales_channels: A flag indicating that this item is available on all channels and limit_sales_channels will be ignored. + :type all_sales_channels: bool + :param limit_sales_channels: A list of sales channel identifiers, that this item is available for sale on. + :type limit_sales_channels: list :param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price :type issue_giftcard: bool :param validity_mode: Instruction how to set ``valid_from``/``valid_until`` on tickets, ``null`` is default event validity. @@ -609,9 +614,14 @@ class Item(LoggedModel): help_text=_('If set, this will be displayed next to the current price to show that the current price is a ' 'discounted one. This is just a cosmetic setting and will not actually impact pricing.') ) - sales_channels = fields.MultiStringField( - verbose_name=_('Sales channels'), - default=['web'], + all_sales_channels = models.BooleanField( + verbose_name=_("Sell on all sales channels"), + default=True, + ) + limit_sales_channels = models.ManyToManyField( + "SalesChannel", + verbose_name=_("Restrict to specific sales channels"), + help_text=_('Only sell tickets for this product on the selected sales channels.'), blank=True, ) issue_giftcard = models.BooleanField( @@ -1033,9 +1043,13 @@ class Item(LoggedModel): return None, None -def _all_sales_channels_identifiers(): - from pretix.base.channels import get_all_sales_channels - return list(get_all_sales_channels().keys()) +def _all_sales_channels_identifiers(): # kept for legacy migrations + from pretix.base.channels import get_all_sales_channel_types + + if "PYTEST_CURRENT_TEST" not in os.environ: + warnings.warn('Method should not be used in new code.', DeprecationWarning) + + return list(get_all_sales_channel_types().keys()) class ItemVariation(models.Model): @@ -1058,6 +1072,10 @@ class ItemVariation(models.Model): :param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after approval by an administrator :type require_approval: bool + :param all_sales_channels: A flag indicating that this variation is available on all channels and limit_sales_channels will be ignored. + :type all_sales_channels: bool + :param limit_sales_channels: A list of sales channel identifiers, that this variation is available for sale on. + :type limit_sales_channels: list """ item = models.ForeignKey( @@ -1143,9 +1161,13 @@ class ItemVariation(models.Model): default=Item.UNAVAIL_MODE_HIDDEN, max_length=16, ) - sales_channels = fields.MultiStringField( - verbose_name=_('Sales channels'), - default=_all_sales_channels_identifiers, + all_sales_channels = models.BooleanField( + verbose_name=_("Sell on all sales channels the product is sold on"), + default=True, + ) + limit_sales_channels = models.ManyToManyField( + "SalesChannel", + verbose_name=_("Restrict to specific sales channels"), help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is ' 'selected here but not on product level, the variation will not be available.'), blank=True, diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index edd82514f0..ad2bbf27bd 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -187,8 +187,8 @@ class Order(LockModel, LoggedModel): :type require_approval: bool :param meta_info: Additional meta information on the order, JSON-encoded. :type meta_info: str - :param sales_channel: Identifier of the sales channel this order was created through. - :type sales_channel: str + :param sales_channel: Foreign key to the sales channel this order was created through. + :type sales_channel: SalesChannel """ STATUS_PENDING = "n" @@ -305,7 +305,10 @@ class Order(LockModel, LoggedModel): require_approval = models.BooleanField( default=False ) - sales_channel = models.CharField(max_length=190, default="web") + sales_channel = models.ForeignKey( + "SalesChannel", + on_delete=models.PROTECT, + ) email_known_to_work = models.BooleanField( default=False, verbose_name=_('E-mail address verified') @@ -1932,7 +1935,7 @@ class OrderPayment(models.Model): trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment ) - if send_mail and self.order.sales_channel in self.order.event.settings.mail_sales_channel_placed_paid: + if send_mail and self.order.sales_channel.identifier in self.order.event.settings.mail_sales_channel_placed_paid: self._send_paid_mail(invoice, user, mail_text) if self.order.event.settings.mail_send_order_paid_attendee: for p in self.order.positions.all(): diff --git a/src/pretix/base/models/organizer.py b/src/pretix/base/models/organizer.py index e45855bbe6..c9e0ee3ba1 100644 --- a/src/pretix/base/models/organizer.py +++ b/src/pretix/base/models/organizer.py @@ -46,7 +46,9 @@ from django.utils.crypto import get_random_string from django.utils.functional import cached_property from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.translation import gettext_lazy as _ +from django_scopes import ScopedManager, scope from i18nfield.fields import I18nCharField +from i18nfield.strings import LazyI18nString from pretix.base.models.base import LoggedModel from pretix.base.validators import OrganizerSlugBanlistValidator @@ -104,6 +106,8 @@ class Organizer(LoggedModel): if is_new: kwargs.pop('update_fields', None) # does not make sense here self.set_defaults() + with scope(organizer=self): + self.create_default_sales_channels() else: self.get_cache().clear() return obj @@ -212,6 +216,24 @@ class Organizer(LoggedModel): else: return get_connection(fail_silently=False) + def create_default_sales_channels(self): + from pretix.base.channels import get_all_sales_channel_types + + i = 0 + for channel in get_all_sales_channel_types().values(): + if not channel.default_created: + continue + + self.sales_channels.get_or_create( + identifier=channel.identifier, + defaults={ + 'label': LazyI18nString.from_gettext(channel.verbose_name), + 'type': channel.identifier, + }, + position=i + ) + i += 1 + def generate_invite_token(): return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) @@ -504,3 +526,58 @@ class OrganizerFooterLink(models.Model): def save(self, *args, **kwargs): super().save(*args, **kwargs) self.organizer.cache.clear() + + +class SalesChannel(LoggedModel): + organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='sales_channels') + label = I18nCharField( + max_length=200, + verbose_name=_("Name"), + ) + identifier = models.CharField( + verbose_name=_("Identifier"), + max_length=200, + validators=[ + RegexValidator( + regex=r"^[a-zA-Z0-9.\-_]+$", + message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores."), + ), + ], + ) + type = models.CharField( + verbose_name=_("Type"), + max_length=200, + ) + position = models.PositiveIntegerField( + default=0, + verbose_name=_("Position") + ) + configuration = models.JSONField(default=dict) + + objects = ScopedManager(organizer="organizer") + + class Meta: + ordering = ("position", "type", "identifier", "id") + unique_together = ("organizer", "identifier") + + def __str__(self): + return str(self.label) + + @cached_property + def type_instance(self): + from ..channels import get_all_sales_channel_types + + types = get_all_sales_channel_types() + return types[self.type] + + @property + def icon(self): + return self.type_instance.icon + + def allow_delete(self): + from . import Order + + if self.type_instance.default_created: + return False + + return not Order.objects.filter(sales_channel=self).exists() diff --git a/src/pretix/base/models/seating.py b/src/pretix/base/models/seating.py index 7d311f66cc..c55d635508 100644 --- a/src/pretix/base/models/seating.py +++ b/src/pretix/base/models/seating.py @@ -243,10 +243,14 @@ class Seat(models.Model): qs_annotated = qs_annotated.annotate(has_closeby_taken=Exists(sq_closeby)) return qs_annotated - def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web', + def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, + sales_channel='web', ignore_distancing=False, distance_ignore_cart_id=None): from .orders import Order + from .organizer import SalesChannel + if isinstance(sales_channel, SalesChannel): + sales_channel = sales_channel.identifier if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel: return False opqs = self.orderposition_set.filter( diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 955f57e824..2f4148879a 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -56,7 +56,6 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.strings import LazyI18nString -from pretix.base.channels import get_all_sales_channels from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator from pretix.base.models import ( CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment, @@ -417,8 +416,8 @@ class BasePaymentProvider: forms.MultipleChoiceField( label=_('Restrict to specific sales channels'), choices=( - (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() - if c.payment_restrictions_supported + (c.identifier, c.label) for c in self.event.organizer.sales_channels.all() + if c.type_instance.payment_restrictions_supported ), initial=['web'], widget=forms.CheckboxSelectMultiple, @@ -853,7 +852,7 @@ class BasePaymentProvider: if str(ia.country) != '' and str(ia.country) not in restricted_countries: return False - if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']): + if order.sales_channel.identifier not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']): return False return self._is_available_by_time(order=order) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index a3e7b7f09e..f989d77e11 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -52,12 +52,11 @@ from django.utils.translation import ( ) from django_scopes import scopes_disabled -from pretix.base.channels import get_all_sales_channels from pretix.base.i18n import language from pretix.base.media import MEDIA_TYPES from pretix.base.models import ( - CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat, - SeatCategoryMapping, Voucher, + CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel, + Seat, SeatCategoryMapping, Voucher, ) from pretix.base.models.event import SubEvent from pretix.base.models.orders import OrderFee @@ -275,8 +274,8 @@ class CartManager: AddOperation: 30 } - def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None, - sales_channel='web'): + def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel, + invoice_address: InvoiceAddress=None, widget_data=None): self.event = event self.cart_id = cart_id self.real_now_dt = now() @@ -384,7 +383,7 @@ class CartManager: }) def _check_max_cart_size(self): - if not get_all_sales_channels()[self._sales_channel].unlimited_items_per_order: + if not self._sales_channel.type_instance.unlimited_items_per_order: cartsize = self.positions.filter(addon_to__isnull=True).count() cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to]) cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if @@ -422,8 +421,13 @@ class CartManager: elif op.item.media_policy == Item.MEDIA_POLICY_REUSE: raise CartError(error_messages['media_usage_not_implemented']) - if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels): - raise CartError(error_messages['unavailable']) + if not op.item.all_sales_channels: + if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()): + raise CartError(error_messages['unavailable']) + + if op.variation and not op.variation.all_sales_channels: + if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()): + raise CartError(error_messages['unavailable']) if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available(): raise CartError(error_messages['not_for_sale']) @@ -457,7 +461,14 @@ class CartManager: raise CartError(error_messages['ended']) seated = self._is_seated(op.item, op.subevent) - if seated and (not op.seat or (op.seat.blocked and self._sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel)): + if ( + seated and ( + not op.seat or ( + op.seat.blocked and + self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel + ) + ) + ): raise CartError(error_messages['seat_invalid']) elif op.seat and not seated: raise CartError(error_messages['seat_forbidden']) @@ -1371,7 +1382,7 @@ class CartManager: discount_results = apply_discounts( self.event, - self._sales_channel, + self._sales_channel.identifier, [ (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in positions @@ -1505,6 +1516,11 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo except InvoiceAddress.DoesNotExist: pass + try: + sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) + except SalesChannel.DoesNotExist: + raise CartError("Invalid sales channel.") + try: try: cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data, @@ -1526,6 +1542,10 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e :param session: Session ID of a guest """ with language(locale), time_machine_now_assigned(override_now_dt): + try: + sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) + except SalesChannel.DoesNotExist: + raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) @@ -1546,6 +1566,10 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l :param session: Session ID of a guest """ with language(locale), time_machine_now_assigned(override_now_dt): + try: + sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) + except SalesChannel.DoesNotExist: + raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) @@ -1565,6 +1589,10 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel :param session: Session ID of a guest """ with language(locale), time_machine_now_assigned(override_now_dt): + try: + sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) + except SalesChannel.DoesNotExist: + raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) @@ -1593,6 +1621,10 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l ia = InvoiceAddress.objects.get(pk=invoice_address) except InvoiceAddress.DoesNotExist: pass + try: + sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) + except SalesChannel.DoesNotExist: + raise CartError("Invalid sales channel.") try: try: cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel) diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 661160922a..b8d94c7df9 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -1159,7 +1159,7 @@ def order_placed(sender, **kwargs): order = kwargs['order'] event = sender - cls = list(event.checkin_lists.filter(auto_checkin_sales_channels__contains=order.sales_channel).prefetch_related( + cls = list(event.checkin_lists.filter(auto_checkin_sales_channels=order.sales_channel).prefetch_related( 'limit_products')) if not cls: return diff --git a/src/pretix/base/services/invoices.py b/src/pretix/base/services/invoices.py index 25a90e0340..e9a0c7022a 100644 --- a/src/pretix/base/services/invoices.py +++ b/src/pretix/base/services/invoices.py @@ -420,7 +420,7 @@ def invoice_pdf_task(invoice: int): def invoice_qualified(order: Order): if order.total == Decimal('0.00') or order.require_approval or \ - order.sales_channel not in order.event.settings.get('invoice_generate_sales_channels'): + order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'): return False return True @@ -443,8 +443,11 @@ def build_preview_invoice_pdf(event): locale = event.settings.locale with rolledback_transaction(), language(locale, event.settings.region): - order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(), - expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count()) + order = event.orders.create( + status=Order.STATUS_PENDING, datetime=timezone.now(), + expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count(), + sales_channel=event.organizer.sales_channels.get(identifier="web"), + ) invoice = Invoice( order=order, event=event, invoice_no="PREVIEW", date=timezone.now().date(), locale=locale, organizer=event.organizer diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 8697a98303..7a9f189d22 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -62,7 +62,6 @@ from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy from django_scopes import scopes_disabled from pretix.api.models import OAuthApplication -from pretix.base.channels import get_all_sales_channels from pretix.base.email import get_email_context from pretix.base.i18n import get_language_without_region, language from pretix.base.media import MEDIA_TYPES @@ -76,7 +75,7 @@ from pretix.base.models.orders import ( BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund, generate_secret, ) -from pretix.base.models.organizer import TeamAPIToken +from pretix.base.models.organizer import SalesChannel, TeamAPIToken from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.payment import GiftCardPayment, PaymentException from pretix.base.reldate import RelativeDateWrapper @@ -650,8 +649,7 @@ def _check_date(event: Event, now_dt: datetime): def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: datetime, positions: List[CartPosition], - address: InvoiceAddress = None, - sales_channel='web', customer=None): + sales_channel: SalesChannel, address: InvoiceAddress=None, customer=None): err = None _check_date(event, time_machine_now_dt) @@ -775,7 +773,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti 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, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel): + if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier): err = err or error_messages['seat_unavailable'] delete(cp) continue @@ -873,7 +871,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] discount_results = apply_discounts( event, - sales_channel, + sales_channel.identifier, [ (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) for cp in sorted_positions @@ -959,12 +957,11 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre return fees -def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, - payment_requests: List[dict], locale: str=None, address: InvoiceAddress=None, - meta_info: dict=None, sales_channel: str='web', shown_total=None, +def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime, + payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None, + address: InvoiceAddress=None, meta_info: dict=None, shown_total=None, customer=None, valid_if_pending=False): payments = [] - sales_channel = get_all_sales_channels()[sales_channel] try: validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode) @@ -986,10 +983,10 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d datetime=now_dt, locale=get_language_without_region(locale), total=total, - testmode=True if sales_channel.testmode_supported and event.testmode else False, + testmode=True if sales_channel.type_instance.testmode_supported and event.testmode else False, meta_info=json.dumps(meta_info or {}), require_approval=require_approval, - sales_channel=sales_channel.identifier, + sales_channel=sales_channel, customer=customer, valid_if_pending=valid_if_pending, ) @@ -1108,6 +1105,11 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis if customer: customer = event.organizer.customers.get(pk=customer) + try: + sales_channel = event.organizer.sales_channels.get(identifier=sales_channel) + except SalesChannel.DoesNotExist: + raise OrderError("Invalid sales channel.") + if email == settings.PRETIX_EMAIL_NONE_VALUE: email = None @@ -1186,9 +1188,20 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis if 'sleep-after-quota-check' in debugflags_var.get(): sleep(2) - order, payment_objs = _create_order(event, email, positions, real_now_dt, payment_requests, - locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, - shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending) + order, payment_objs = _create_order( + event, + email=email, + positions=positions, + now_dt=real_now_dt, + payment_requests=payment_requests, + locale=locale, + address=addr, + meta_info=meta_info, + sales_channel=sales_channel, + shown_total=shown_total, + customer=customer, + valid_if_pending=valid_if_pending + ) try: for p in payment_objs: @@ -1272,7 +1285,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis email_attendees_template = event.settings.mail_text_order_placed_attendee subject_attendees_template = event.settings.mail_subject_order_placed_attendee - if sales_channel in event.settings.mail_sales_channel_placed_paid: + if sales_channel.identifier in event.settings.mail_sales_channel_placed_paid: _order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs, is_free=free_order_flow) if email_attendees: @@ -1424,7 +1437,7 @@ def send_download_reminders(sender, **kwargs): if days is None: continue - if o.sales_channel not in event.settings.mail_sales_channel_download_reminder: + if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder: continue reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0) @@ -1926,9 +1939,13 @@ class OrderChangeManager: if not item.is_available() or (variation and not variation.is_available()): raise OrderError(error_messages['unavailable']) - if self.order.sales_channel not in item.sales_channels or ( - variation and self.order.sales_channel not in variation.sales_channels): - raise OrderError(error_messages['unavailable']) + if not item.all_sales_channels: + if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()): + raise OrderError(error_messages['unavailable']) + + if variation and not variation.all_sales_channels: + if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()): + raise OrderError(error_messages['unavailable']) if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(): raise OrderError(error_messages['not_for_sale']) @@ -2027,7 +2044,10 @@ class OrderChangeManager: (a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent)) or (a.variation and self.order.sales_channel not in a.variation.sales_channels) or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent) - or self.order.sales_channel not in item.sales_channels + or ( + not item.all_sales_channels and + not item.limit_sales_channels.contains(self.order.sales_channel) + ) ) if is_unavailable: continue diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index 9c4a12ffb5..acfdc44b0d 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -28,7 +28,8 @@ from django.db.models import Q from pretix.base.decimal import round_decimal from pretix.base.models import ( - AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher, + AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, + SalesChannel, Voucher, ) from pretix.base.models.event import Event, SubEvent from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule @@ -164,12 +165,14 @@ def apply_discounts(event: Event, sales_channel: str, :param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` :return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input """ + if isinstance(sales_channel, SalesChannel): + sales_channel = sales_channel.identifier new_prices = {} discount_qs = event.discounts.filter( Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()), Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()), - sales_channels__contains=sales_channel, + Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=sales_channel), active=True, ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') for discount in discount_qs: diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index dc8467d2c4..4f6cb19025 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -294,13 +294,16 @@ This signal is sent out when a notification is sent. As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ -register_sales_channels = django.dispatch.Signal() +register_sales_channel_types = django.dispatch.Signal() """ This signal is sent out to get all known sales channels types. Receivers should return an -instance of a subclass of ``pretix.base.channels.SalesChannel`` or a list of such +instance of a subclass of ``pretix.base.channels.SalesChannelType`` or a list of such instances. """ + +register_sales_channels = DeprecatedSignal() # TODO: remove me + register_data_exporters = EventPluginSignal() """ This signal is sent out to get all known data exporters. Receivers should return a diff --git a/src/pretix/base/templates/pretixbase/forms/widgets/checkbox_sales_channel_option.html b/src/pretix/base/templates/pretixbase/forms/widgets/checkbox_sales_channel_option.html new file mode 100644 index 0000000000..9492b2376d --- /dev/null +++ b/src/pretix/base/templates/pretixbase/forms/widgets/checkbox_sales_channel_option.html @@ -0,0 +1,19 @@ +{% load rich_text %} +{% load static %} +{% load i18n %} +{% if widget.wrap_label %}{% endif %} +{% include "django/forms/widgets/input.html" %} +{% if widget.wrap_label %} +{% if "." in widget.value.instance.type_instance.icon %} + +{% else %} + +{% endif %} +{% if widget.plugin_missing %} + +{% endif %} +{{ widget.label }}{% if widget.plugin_missing %} + +{% endif %} + +{% endif %} diff --git a/src/pretix/control/forms/__init__.py b/src/pretix/control/forms/__init__.py index f406774ec4..42a0fdf6d2 100644 --- a/src/pretix/control/forms/__init__.py +++ b/src/pretix/control/forms/__init__.py @@ -438,3 +438,20 @@ class ButtonGroupRadioSelect(forms.RadioSelect): attrs['icon'] = self.option_icons[value] opt = super().create_option(name, value, label, selected, index, subindex, attrs) return opt + + +class SalesChannelCheckboxSelectMultiple(forms.CheckboxSelectMultiple): + option_template_name = 'pretixbase/forms/widgets/checkbox_sales_channel_option.html' + + def __init__(self, event, attrs=None, choices=()): + self.event = event + super().__init__(attrs, choices) + + def create_option( + self, name, value, label, selected, index, subindex=None, attrs=None + ): + plugin = value.instance.type_instance.required_event_plugin + return { + **super().create_option(name, value, label, selected, index, subindex, attrs), + "plugin_missing": plugin and plugin not in self.event.get_plugins(), + } diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py index 0012cfcf8d..52a52217fd 100644 --- a/src/pretix/control/forms/checkin.py +++ b/src/pretix/control/forms/checkin.py @@ -30,11 +30,12 @@ from django_scopes.forms import ( SafeModelChoiceField, SafeModelMultipleChoiceField, ) -from pretix.base.channels import get_all_sales_channels from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.models import Gate from pretix.base.models.checkin import Checkin, CheckinList -from pretix.control.forms import ItemMultipleChoiceField +from pretix.control.forms import ( + ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple, +) from pretix.control.forms.widgets import Select2 @@ -66,14 +67,9 @@ class CheckinListForm(forms.ModelForm): kwargs.pop('locales', None) super().__init__(**kwargs) self.fields['limit_products'].queryset = self.event.items.all() - self.fields['auto_checkin_sales_channels'] = forms.MultipleChoiceField( - label=self.fields['auto_checkin_sales_channels'].label, - help_text=self.fields['auto_checkin_sales_channels'].help_text, - required=self.fields['auto_checkin_sales_channels'].required, - choices=( - (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() - ), - widget=forms.CheckboxSelectMultiple + self.fields['auto_checkin_sales_channels'].queryset = self.event.organizer.sales_channels.all() + self.fields['auto_checkin_sales_channels'].widget = SalesChannelCheckboxSelectMultiple( + self.event, choices=self.fields['auto_checkin_sales_channels'].widget.choices ) if not self.event.organizer.gates.exists(): @@ -123,13 +119,13 @@ class CheckinListForm(forms.ModelForm): 'gates': forms.CheckboxSelectMultiple(attrs={ 'class': 'scrolling-multiple-choice' }), - 'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(), 'exit_all_at': NextTimeInput(attrs={'class': 'timepickerfield'}), } field_classes = { 'limit_products': ItemMultipleChoiceField, 'gates': SafeModelMultipleChoiceField, 'subevent': SafeModelChoiceField, + 'auto_checkin_sales_channels': SafeModelMultipleChoiceField, 'exit_all_at': NextTimeField, } diff --git a/src/pretix/control/forms/discounts.py b/src/pretix/control/forms/discounts.py index d61dd3aef5..aac9e08270 100644 --- a/src/pretix/control/forms/discounts.py +++ b/src/pretix/control/forms/discounts.py @@ -22,13 +22,16 @@ from decimal import Decimal from django import forms -from django.utils.translation import gettext_lazy as _ +from django_scopes.forms import SafeModelMultipleChoiceField -from pretix.base.channels import get_all_sales_channels +from pretix.base.channels import get_all_sales_channel_types from pretix.base.forms import I18nModelForm from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.models import Discount -from pretix.control.forms import ItemMultipleChoiceField, SplitDateTimeField +from pretix.control.forms import ( + ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple, + SplitDateTimeField, +) class DiscountForm(I18nModelForm): @@ -38,7 +41,8 @@ class DiscountForm(I18nModelForm): fields = [ 'active', 'internal_name', - 'sales_channels', + 'all_sales_channels', + 'limit_sales_channels', 'available_from', 'available_until', 'subevent_mode', @@ -60,6 +64,7 @@ class DiscountForm(I18nModelForm): 'available_until': SplitDateTimeField, 'condition_limit_products': ItemMultipleChoiceField, 'benefit_limit_products': ItemMultipleChoiceField, + 'limit_sales_channels': SafeModelMultipleChoiceField, } widgets = { 'subevent_mode': forms.RadioSelect, @@ -83,15 +88,12 @@ class DiscountForm(I18nModelForm): self.event = kwargs['event'] super().__init__(*args, **kwargs) - self.fields['sales_channels'] = forms.MultipleChoiceField( - label=_('Sales channels'), - required=True, - choices=( - (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() - if c.discounts_supported - ), - widget=forms.CheckboxSelectMultiple, + self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.filter( + type__in=[k for k, v in get_all_sales_channel_types().items() if v.discounts_supported] ) + self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={ + 'data-inverse-dependency': '<[name$=all_sales_channels]', + }, choices=self.fields['limit_sales_channels'].widget.choices) self.fields['condition_limit_products'].queryset = self.event.items.all() self.fields['benefit_limit_products'].queryset = self.event.items.all() self.fields['condition_min_count'].required = False diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index a0ca8d3040..584660b527 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -44,9 +44,7 @@ from django.conf import settings from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.core.validators import MaxValueValidator from django.db.models import Prefetch, Q, prefetch_related_objects -from django.forms import ( - CheckboxSelectMultiple, formset_factory, inlineformset_factory, -) +from django.forms import formset_factory, inlineformset_factory from django.urls import reverse from django.utils.functional import cached_property from django.utils.html import escape, format_html @@ -54,12 +52,12 @@ from django.utils.safestring import mark_safe from django.utils.timezone import get_current_timezone_name from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy from django_countries.fields import LazyTypedChoiceField +from django_scopes.forms import SafeModelMultipleChoiceField from i18nfield.forms import ( I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput, ) from pytz import common_timezones -from pretix.base.channels import get_all_sales_channels from pretix.base.forms import ( I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator, SettingsForm, ) @@ -73,8 +71,8 @@ from pretix.base.settings import ( ) from pretix.base.validators import multimail_validate from pretix.control.forms import ( - MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, - SplitDateTimePickerWidget, + MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple, SlugWidget, + SplitDateTimeField, SplitDateTimePickerWidget, ) from pretix.control.forms.widgets import Select2 from pretix.helpers.countries import CachedCountries @@ -378,16 +376,10 @@ class EventUpdateForm(I18nModelForm): required=False, help_text=_('You need to configure the custom domain in the webserver beforehand.') ) - self.fields['sales_channels'] = forms.MultipleChoiceField( - label=self.fields['sales_channels'].label, - help_text=self.fields['sales_channels'].help_text, - required=self.fields['sales_channels'].required, - initial=self.fields['sales_channels'].initial, - choices=( - (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() - ), - widget=forms.CheckboxSelectMultiple - ) + self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all() + self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={ + 'data-inverse-dependency': '<[name$=all_sales_channels]', + }, choices=self.fields['limit_sales_channels'].widget.choices) def clean_domain(self): d = self.cleaned_data['domain'] @@ -444,7 +436,8 @@ class EventUpdateForm(I18nModelForm): 'location', 'geo_lat', 'geo_lon', - 'sales_channels' + 'all_sales_channels', + 'limit_sales_channels', ] field_classes = { 'date_from': SplitDateTimeField, @@ -452,6 +445,7 @@ class EventUpdateForm(I18nModelForm): 'date_admission': SplitDateTimeField, 'presale_start': SplitDateTimeField, 'presale_end': SplitDateTimeField, + 'limit_sales_channels': SafeModelMultipleChoiceField, } widgets = { 'date_from': SplitDateTimePickerWidget(), @@ -459,7 +453,6 @@ class EventUpdateForm(I18nModelForm): 'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}), 'presale_start': SplitDateTimePickerWidget(), 'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}), - 'sales_channels': CheckboxSelectMultiple(), } @@ -915,7 +908,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm): locale_names = dict(settings.LANGUAGES) self.fields['invoice_language'].choices = [('__user__', _('The user\'s language'))] + [(a, locale_names[a]) for a in event.settings.locales] self.fields['invoice_generate_sales_channels'].choices = ( - (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() + (c.identifier, c.label) for c in event.organizer.sales_channels.all() ) self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15)) @@ -961,7 +954,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): ] mail_sales_channel_placed_paid = forms.MultipleChoiceField( - choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()], + choices=[], label=_('Sales channels for checkout emails'), help_text=_('The order placed and paid emails will only be send to orders from these sales channels. ' 'The online shop must be enabled.'), @@ -972,7 +965,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): ) mail_sales_channel_download_reminder = forms.MultipleChoiceField( - choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()], + choices=[], label=_('Sales channels'), help_text=_('This email will only be send to orders from these sales channels. The online shop must be enabled.'), widget=forms.CheckboxSelectMultiple( @@ -1367,6 +1360,12 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm): self.fields['mail_html_renderer'].choices = [ (r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values() ] + self.fields['mail_sales_channel_placed_paid'].choices = ( + (c.identifier, c.label) for c in event.organizer.sales_channels.all() + ) + self.fields['mail_sales_channel_download_reminder'].choices = ( + (c.identifier, c.label) for c in event.organizer.sales_channels.all() + ) prefetch_related_objects([self.event.organizer], Prefetch('meta_properties')) self.event.meta_values_cached = self.event.meta_values.select_related('property').all() diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index 9d6771fb00..4f26abc8ba 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -50,15 +50,14 @@ from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy from django_scopes.forms import SafeModelChoiceField -from pretix.base.channels import get_all_sales_channels from pretix.base.forms.widgets import ( DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget, ) from pretix.base.models import ( Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue, Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, - OrderRefund, Organizer, Question, QuestionAnswer, Quota, SubEvent, - SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher, + OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel, + SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher, ) from pretix.base.signals import register_payment_providers from pretix.control.forms.widgets import Select2, Select2ItemVarQuota @@ -579,9 +578,11 @@ class EventOrderExpertFilterForm(EventOrderFilterForm): required=False, label=_('Maximal sum of payments and refunds'), ) - sales_channel = forms.ChoiceField( + sales_channel = SafeModelChoiceField( label=_('Sales channel'), required=False, + queryset=SalesChannel.objects.none(), + to_field_name="identifier", ) has_checkin = forms.NullBooleanField( required=False, @@ -604,9 +605,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm): del self.fields['subevents_from'] del self.fields['subevents_to'] - self.fields['sales_channel'].choices = [('', '')] + [ - (k, v.verbose_name) for k, v in get_all_sales_channels().items() - ] + self.fields['sales_channel'].queryset = self.event.organizer.sales_channels.all() locale_names = dict(settings.LANGUAGES) self.fields['locale'].choices = [('', '')] + [(a, locale_names[a]) for a in self.event.settings.locales] @@ -719,7 +718,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm): if fdata.get('comment'): qs = qs.filter(comment__icontains=fdata.get('comment')) if fdata.get('sales_channel'): - qs = qs.filter(sales_channel=fdata.get('sales_channel')) + qs = qs.filter(sales_channel__identifier=fdata.get('sales_channel').identifier) if fdata.get('total'): qs = qs.filter(total=fdata.get('total')) if fdata.get('email_known_to_work') is not None: diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 228c222344..6b0669e7ae 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -55,7 +55,6 @@ from django_scopes.forms import ( ) from i18nfield.forms import I18nFormField, I18nTextarea -from pretix.base.channels import get_all_sales_channels from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm from pretix.base.forms.widgets import DatePickerWidget from pretix.base.models import ( @@ -64,7 +63,8 @@ from pretix.base.models import ( from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.signals import item_copy_data from pretix.control.forms import ( - ButtonGroupRadioSelect, ItemMultipleChoiceField, SizeValidationMixin, + ButtonGroupRadioSelect, ItemMultipleChoiceField, + SalesChannelCheckboxSelectMultiple, SizeValidationMixin, SplitDateTimeField, SplitDateTimePickerWidget, ) from pretix.control.forms.widgets import Select2, Select2ItemVarMulti @@ -413,7 +413,7 @@ class ItemCreateForm(I18nModelForm): 'checkin_text', 'free_price', 'original_price', - 'sales_channels', + 'all_sales_channels', 'issue_giftcard', 'require_approval', 'allow_waitinglist', @@ -443,9 +443,6 @@ class ItemCreateForm(I18nModelForm): if src.picture: self.instance.picture.save(os.path.basename(src.picture.name), src.picture) - else: - # Add to all sales channels by default - self.instance.sales_channels = list(get_all_sales_channels().keys()) self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1 if not self.instance.admission: @@ -474,6 +471,8 @@ class ItemCreateForm(I18nModelForm): }) if self.cleaned_data.get('copy_from'): + if not self.instance.all_sales_channels: + self.instance.limit_sales_channels.set(self.cleaned_data['copy_from'].limit_sales_channels.all()) self.instance.require_membership_types.set( self.cleaned_data['copy_from'].require_membership_types.all() ) @@ -574,14 +573,10 @@ class ItemUpdateForm(I18nModelForm): if self.event.tax_rules.exists(): self.fields['tax_rule'].required = True self.fields['description'].widget.attrs['rows'] = '4' - self.fields['sales_channels'] = forms.MultipleChoiceField( - label=_('Sales channels'), - required=False, - choices=( - (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() - ), - widget=forms.CheckboxSelectMultiple - ) + self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all() + self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={ + 'data-inverse-dependency': '<[name$=all_sales_channels]', + }, choices=self.fields['limit_sales_channels'].widget.choices) change_decimal_field(self.fields['default_price'], self.event.currency) self.fields['available_from_mode'].widget = ButtonGroupRadioSelect( @@ -772,7 +767,8 @@ class ItemUpdateForm(I18nModelForm): 'name', 'internal_name', 'active', - 'sales_channels', + 'all_sales_channels', + 'limit_sales_channels', 'admission', 'personalized', 'description', @@ -829,6 +825,7 @@ class ItemUpdateForm(I18nModelForm): 'hidden_if_item_available': SafeModelChoiceField, 'grant_membership_type': SafeModelChoiceField, 'require_membership_types': SafeModelMultipleChoiceField, + 'limit_sales_channels': SafeModelMultipleChoiceField, } widgets = { 'available_from': SplitDateTimePickerWidget(), @@ -900,18 +897,10 @@ class ItemVariationForm(I18nModelForm): qs = kwargs.pop('membership_types') super().__init__(*args, **kwargs) change_decimal_field(self.fields['default_price'], self.event.currency) - self.fields['sales_channels'] = forms.MultipleChoiceField( - label=_('Sales channels'), - required=False, - choices=( - (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() - ), - help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is ' - 'selected here but not on product level, the variation will not be available.'), - widget=forms.CheckboxSelectMultiple - ) - if not self.instance.pk: - self.initial.setdefault('sales_channels', list(get_all_sales_channels().keys())) + self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all() + self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={ + 'data-inverse-dependency': '<[name$=all_sales_channels]', + }, choices=self.fields['limit_sales_channels'].widget.choices) self.fields['description'].widget.attrs['rows'] = 3 if qs: @@ -983,12 +972,14 @@ class ItemVariationForm(I18nModelForm): 'available_from_mode', 'available_until', 'available_until_mode', - 'sales_channels', + 'all_sales_channels', + 'limit_sales_channels', 'hide_without_voucher', ] field_classes = { 'available_from': SplitDateTimeField, 'available_until': SplitDateTimeField, + 'limit_sales_channels': SafeModelMultipleChoiceField, } widgets = { 'available_from': SplitDateTimePickerWidget(), diff --git a/src/pretix/control/forms/organizer.py b/src/pretix/control/forms/organizer.py index d22d59c2f6..3f5c667964 100644 --- a/src/pretix/control/forms/organizer.py +++ b/src/pretix/control/forms/organizer.py @@ -50,6 +50,7 @@ from django_scopes.forms import SafeModelChoiceField from i18nfield.forms import ( I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput, ) +from i18nfield.strings import LazyI18nString from phonenumber_field.formfields import PhoneNumberField from pytz import common_timezones @@ -68,7 +69,8 @@ from pretix.base.forms.widgets import ( ) from pretix.base.models import ( Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance, - Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, Team, + Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, + SalesChannel, Team, ) from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.organizer import OrganizerFooterLink @@ -1090,3 +1092,40 @@ class GiftCardAcceptanceInviteForm(forms.Form): if self.organizer.gift_card_acceptor_acceptance.filter(acceptor=acceptor).exists(): raise ValidationError(_('The selected organizer has already been invited.')) return acceptor + + +class SalesChannelForm(I18nModelForm): + class Meta: + model = SalesChannel + fields = ['label', 'identifier'] + widgets = { + 'default': forms.TextInput(), + } + + def __init__(self, *args, **kwargs): + self.type = kwargs.pop("type") + super().__init__(*args, **kwargs) + + if not self.type.multiple_allowed or (self.instance and self.instance.pk): + self.fields["identifier"].initial = self.type.identifier + self.fields["identifier"].disabled = True + self.fields["label"].initial = LazyI18nString.from_gettext(self.type.verbose_name) + + def clean(self): + d = super().clean() + + if self.instance.pk: + d["identifier"] = self.instance.identifier + elif self.type.multiple_allowed: + d["identifier"] = self.type.identifier + "." + d["identifier"] + else: + d["identifier"] = self.type.identifier + + if not self.instance.pk: + # self.event is actually the organizer, sorry I18nModelForm! + if self.event.sales_channels.filter(identifier=d["identifier"]).exists(): + raise ValidationError( + _("A sales channel with the same identifier already exists.") + ) + + return d diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 8c2a49b598..44da167da7 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -357,6 +357,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.membershiptype.created': _('The membership type has been created.'), 'pretix.membershiptype.changed': _('The membership type has been changed.'), 'pretix.membershiptype.deleted': _('The membership type has been deleted.'), + 'pretix.saleschannel.created': _('The sales channel has been created.'), + 'pretix.saleschannel.changed': _('The sales channel has been changed.'), + 'pretix.saleschannel.deleted': _('The sales channel has been deleted.'), 'pretix.customer.created': _('The account has been created.'), 'pretix.customer.changed': _('The account has been changed.'), 'pretix.customer.membership.created': _('A membership for this account has been added.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 46b8cc5a32..4fb90f823d 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -502,6 +502,13 @@ def get_organizer_navigation(request): }), 'active': url.url_name == 'organizer.settings.mail', }, + { + 'label': _('Sales channels'), + 'url': reverse('control:organizer.channels', kwargs={ + 'organizer': request.organizer.slug + }), + 'active': url.url_name.startswith('organizer.channel'), + }, { 'label': _('Webhooks'), 'url': reverse('control:organizer.webhooks', kwargs={ diff --git a/src/pretix/control/templates/pretixcontrol/checkin/lists.html b/src/pretix/control/templates/pretixcontrol/checkin/lists.html index 5a0246dce3..00d3de598a 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/lists.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/lists.html @@ -1,6 +1,7 @@ {% extends "pretixcontrol/items/base.html" %} {% load i18n %} {% load bootstrap3 %} +{% load static %} {% load urlreplace %} {% block title %}{% trans "Check-in lists" %}{% endblock %} {% block inside %} @@ -137,9 +138,14 @@ {% endif %} {% endif %} - {% for channel in cl.auto_checkin_sales_channels %} - + {% for channel in cl.auto_checkin_sales_channels.all %} + {% if "." in channel.icon %} + + {% else %} + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index cb47b67347..1583ff9d6c 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -1,5 +1,6 @@ {% extends "pretixcontrol/event/settings_base.html" %} {% load i18n %} +{% load static %} {% load bootstrap3 %} {% block inside %}

{% trans "Payment settings" %}

@@ -30,8 +31,13 @@ {% for channel in provider.sales_channels %} - + {% if "." in channel.icon %} + + {% else %} + + {% endif %} {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index db89dc9967..befac052fb 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -32,7 +32,8 @@ {% bootstrap_field sform.contact_mail layout="control" %} {% bootstrap_field sform.imprint_url layout="control" %} {% bootstrap_field form.is_public layout="control" %} - {% bootstrap_field form.sales_channels layout="control" %} + {% bootstrap_field form.all_sales_channels layout="control" %} + {% bootstrap_field form.limit_sales_channels layout="control" %} {% if meta_forms %}