From b4290384e1a7b3c6cdce1c62cce8182f6a5754a9 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 23 Nov 2018 15:35:09 +0100 Subject: [PATCH] Add sales channels (#1103) - [x] Data model - [x] Enforce constraint - [x] Filter order list - [x] Set channel on created order - [x] Products API - [x] Order API - [x] Tests - [x] Filter reports - [x] Resellers - [ ] deploy plugins - [ ] posbackend - [ ] resellers - [ ] reports - [x] Ticketlayouts - [x] Support in pretixPOS --- doc/api/resources/items.rst | 11 +++ doc/api/resources/orders.rst | 12 +++ doc/plugins/ticketoutputpdf.rst | 5 + src/pretix/api/serializers/item.py | 2 +- src/pretix/api/serializers/order.py | 10 +- src/pretix/base/channels.py | 66 +++++++++++++ .../migrations/0103_auto_20181121_1224.py | 29 ++++++ src/pretix/base/models/fields.py | 94 +++++++++++++++++++ src/pretix/base/models/items.py | 7 ++ src/pretix/base/models/orders.py | 3 + src/pretix/base/services/cart.py | 16 +++- src/pretix/base/services/orders.py | 15 +-- src/pretix/base/signals.py | 9 ++ src/pretix/control/forms/item.py | 13 +++ .../templates/pretixcontrol/item/index.html | 1 + .../templates/pretixcontrol/order/index.html | 4 + src/pretix/control/views/item.py | 5 +- src/pretix/control/views/orders.py | 2 + src/pretix/plugins/ticketoutputpdf/api.py | 2 +- .../plugins/ticketoutputpdf/exporters.py | 8 +- src/pretix/plugins/ticketoutputpdf/forms.py | 12 ++- .../migrations/0007_auto_20181123_1059.py | 29 ++++++ src/pretix/plugins/ticketoutputpdf/models.py | 8 +- src/pretix/plugins/ticketoutputpdf/signals.py | 35 +++---- .../plugins/ticketoutputpdf/ticketoutput.py | 18 +++- src/pretix/presale/checkoutflow.py | 9 +- src/pretix/presale/forms/checkout.py | 3 +- src/pretix/presale/utils.py | 3 + src/pretix/presale/views/cart.py | 4 +- src/pretix/presale/views/event.py | 6 +- src/pretix/presale/views/widget.py | 2 +- src/tests/api/test_items.py | 3 + src/tests/api/test_orders.py | 34 +++++++ src/tests/control/test_items.py | 5 +- src/tests/plugins/badges/test_control.py | 4 +- src/tests/plugins/ticketoutputpdf/test_api.py | 2 +- .../plugins/ticketoutputpdf/test_control.py | 4 +- src/tests/presale/test_cart.py | 16 ++++ src/tests/presale/test_event.py | 18 +++- 39 files changed, 472 insertions(+), 57 deletions(-) create mode 100644 src/pretix/base/channels.py create mode 100644 src/pretix/base/migrations/0103_auto_20181121_1224.py create mode 100644 src/pretix/base/models/fields.py create mode 100644 src/pretix/plugins/ticketoutputpdf/migrations/0007_auto_20181123_1059.py diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 001a9f4b45..a8d7470766 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -37,6 +37,8 @@ admission boolean ``True`` for it position integer An integer, used for sorting picture string A product picture to be displayed in the shop (read-only). +sales_channels list of strings Sales channels this product is available on, such as + ``"web"`` or ``"resellers"``. Defaults to ``["web"]``. available_from datetime The first date time at which this item can be bought (or ``null``). available_until datetime The last date time at which this item can be bought @@ -105,6 +107,10 @@ addons list of objects Definition of a The field ``require_approval`` has been added. +.. versionchanged:: 2.3 + + The ``sales_channels`` attribute has been added. + Notes ----- Please note that an item either always has variations or never has. Once created with variations the item can never @@ -147,6 +153,7 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", + "sales_channels": ["web"], "default_price": "23.00", "original_price": null, "category": null, @@ -232,6 +239,7 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", + "sales_channels": ["web"], "default_price": "23.00", "original_price": null, "category": null, @@ -298,6 +306,7 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", + "sales_channels": ["web"], "default_price": "23.00", "original_price": null, "category": null, @@ -351,6 +360,7 @@ Endpoints "id": 1, "name": {"en": "Standard ticket"}, "internal_name": "", + "sales_channels": ["web"], "default_price": "23.00", "original_price": null, "category": null, @@ -436,6 +446,7 @@ Endpoints "id": 1, "name": {"en": "Ticket"}, "internal_name": "", + "sales_channels": ["web"], "default_price": "25.00", "original_price": null, "category": null, diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index b5d8096d8a..1d60ea533b 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -30,6 +30,8 @@ status string Order status, o secret string The secret contained in the link sent to the customer email string The customer email address locale string The locale used for communication with this customer +sales_channel string Channel this sale was created through, such as + ``"web"``. datetime datetime Time of order creation expires datetime The order will expire, if it is still pending by this time payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt @@ -121,6 +123,10 @@ last_modified datetime Last modificati nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval`` attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints. +.. versionchanged:: 2.3 + + The ``sales_channel`` attribute has been added. + .. _order-position-resource: Order position resource @@ -265,6 +271,7 @@ List of all orders "secret": "k24fiuwvu8kxz3y1", "email": "tester@example.org", "locale": "en", + "sales_channel": "web", "datetime": "2017-12-01T10:00:00Z", "expires": "2017-12-10T10:00:00Z", "last_modified": "2017-12-01T10:00:00Z", @@ -401,6 +408,7 @@ Fetching individual orders "secret": "k24fiuwvu8kxz3y1", "email": "tester@example.org", "locale": "en", + "sales_channel": "web", "datetime": "2017-12-01T10:00:00Z", "expires": "2017-12-10T10:00:00Z", "last_modified": "2017-12-01T10:00:00Z", @@ -561,6 +569,8 @@ Creating orders * does not validate if products are only to be sold in a specific time frame + * does not validate if products are only to be sold on other sales channels + * does not validate if the event's ticket sales are already over or haven't started * does not validate the number of items per order or the number of times an item can be included in an order @@ -597,6 +607,7 @@ Creating orders creation. * ``email`` * ``locale`` + * ``sales_channel`` * ``payment_provider`` – The identifier of the payment provider set for this order. This needs to be an existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all orders you create as paid. @@ -661,6 +672,7 @@ Creating orders { "email": "dummy@example.org", "locale": "en", + "sales_channel": "web", "fees": [ { "fee_type": "payment", diff --git a/doc/plugins/ticketoutputpdf.rst b/doc/plugins/ticketoutputpdf.rst index e73581b708..2964c12843 100644 --- a/doc/plugins/ticketoutputpdf.rst +++ b/doc/plugins/ticketoutputpdf.rst @@ -20,6 +20,7 @@ default boolean ``true`` if thi layout object Layout specification for libpretixprint background URL Background PDF file item_assignments list of objects Products this layout is assigned to +├ sales_channel string Sales channel (defaults to ``web``). └ item integer Item ID ===================================== ========================== ======================================================= @@ -27,6 +28,10 @@ item_assignments list of objects Products this l This resource has been added. +.. versionchanged:: 2.3 + + The ``item_assignments.sales_channel`` field has been added. + Endpoints --------- diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 3f963086a3..79d4b30552 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -74,7 +74,7 @@ class ItemSerializer(I18nAwareModelSerializer): class Meta: model = Item - fields = ('id', 'category', 'name', 'internal_name', 'active', 'description', + fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', 'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission', 'position', 'picture', 'available_from', 'available_until', 'require_voucher', 'hide_without_voucher', 'allow_cancel', diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index e385ccaab3..33904f425c 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -11,6 +11,7 @@ from rest_framework.relations import SlugRelatedField from rest_framework.reverse import reverse from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.channels import get_all_sales_channels from pretix.base.models import ( Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question, QuestionAnswer, @@ -232,7 +233,7 @@ class OrderSerializer(I18nAwareModelSerializer): model = Order fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', - 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval') + 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -412,7 +413,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer): class Meta: model = Order - fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', + fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts') def validate_payment_provider(self, pp): @@ -420,6 +421,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer): raise ValidationError('The given payment provider is not known.') return pp + 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( diff --git a/src/pretix/base/channels.py b/src/pretix/base/channels.py new file mode 100644 index 0000000000..8e4855587f --- /dev/null +++ b/src/pretix/base/channels.py @@ -0,0 +1,66 @@ +import logging +from collections import OrderedDict + +from django.dispatch import receiver +from django.utils.translation import ugettext_lazy as _ + +from pretix.base.signals import register_sales_channels + +logger = logging.getLogger(__name__) +_ALL_CHANNELS = None + + +class SalesChannel: + def __repr__(self): + return ''.format(self.identifier) + + @property + def identifier(self) -> str: + """ + The internal identifier of this sales channel. + """ + raise NotImplementedError() # NOQA + + @property + def verbose_name(self) -> str: + """ + A human-readable name of this sales channel. + """ + raise NotImplementedError() # NOQA + + @property + def icon(self) -> str: + """ + The name of a Font Awesome icon to represent this channel + """ + return "circle" + + +def get_all_sales_channels(): + global _ALL_CHANNELS + + if _ALL_CHANNELS: + return _ALL_CHANNELS + + types = OrderedDict() + for recv, ret in register_sales_channels.send(None): + if isinstance(ret, (list, tuple)): + for r in ret: + types[r.identifier] = r + else: + types[ret.identifier] = ret + _ALL_CHANNELS = types + return types + + +class WebshopSalesChannel(SalesChannel): + identifier = "web" + verbose_name = _('Online shop') + icon = "globe" + + +@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels") +def base_sales_channels(sender, **kwargs): + return ( + WebshopSalesChannel(), + ) diff --git a/src/pretix/base/migrations/0103_auto_20181121_1224.py b/src/pretix/base/migrations/0103_auto_20181121_1224.py new file mode 100644 index 0000000000..5c6b3b78ef --- /dev/null +++ b/src/pretix/base/migrations/0103_auto_20181121_1224.py @@ -0,0 +1,29 @@ +# Generated by Django 2.1.1 on 2018-11-21 12:24 + +import django.db.models.deletion +import jsonfallback.fields +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0102_auto_20181017_0024'), + ] + + operations = [ + migrations.AddField( + model_name='item', + name='sales_channels', + field=pretix.base.models.fields.MultiStringField(default=['web'], verbose_name='Sales channels'), + preserve_default=False, + ), + migrations.AddField( + model_name='order', + name='sales_channel', + field=models.CharField(default='web', max_length=190), + preserve_default=False, + ), + ] diff --git a/src/pretix/base/models/fields.py b/src/pretix/base/models/fields.py new file mode 100644 index 0000000000..67c782fc92 --- /dev/null +++ b/src/pretix/base/models/fields.py @@ -0,0 +1,94 @@ +from django.core import exceptions +from django.db.models import TextField, lookups as builtin_lookups +from django.utils.translation import gettext_lazy as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +DELIMITER = "\x1F" + + +class MultiStringField(TextField): + default_error_messages = { + 'delimiter_found': _('No value can contain the delimiter character.') + } + + def __init__(self, verbose_name=None, name=None, **kwargs): + super().__init__(verbose_name, name, **kwargs) + + def deconstruct(self): + name, path, args, kwargs = super().deconstruct() + return name, path, args, kwargs + + def to_python(self, value): + if isinstance(value, (list, tuple)): + return value + elif value: + return [v for v in value.split(DELIMITER) if v] + else: + return [] + + def get_prep_value(self, value): + if isinstance(value, (list, tuple)): + return DELIMITER + DELIMITER.join(value) + DELIMITER + elif value is None: + return "" + raise TypeError("Invalid data type passed.") + + def get_prep_lookup(self, lookup_type, value): # NOQA + raise TypeError('Lookups on multi strings are currently not supported.') + + def from_db_value(self, value, expression, connection, context): + if value: + return [v for v in value.split(DELIMITER) if v] + else: + return [] + + def validate(self, value, model_instance): + super().validate(value, model_instance) + for l in value: + if DELIMITER in l: + raise exceptions.ValidationError( + self.error_messages['delimiter_found'], + code='delimiter_found', + ) + + def get_lookup(self, lookup_name): + if lookup_name == 'contains': + return MultiStringContains + elif lookup_name == 'icontains': + return MultiStringIContains + raise NotImplementedError( + "Lookup '{}' doesn't work with MultiStringField".format(lookup_name), + ) + + +class MultiStringContains(builtin_lookups.Contains): + def process_rhs(self, qn, connection): + sql, params = super().process_rhs(qn, connection) + params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%" + return sql, params + + +class MultiStringIContains(builtin_lookups.IContains): + def process_rhs(self, qn, connection): + sql, params = super().process_rhs(qn, connection) + params[0] = "%" + DELIMITER + params[0][1:-1] + DELIMITER + "%" + return sql, params + + +class MultiStringSerializer(serializers.Field): + def __init__(self, **kwargs): + self.allow_blank = kwargs.pop('allow_blank', False) + super().__init__(**kwargs) + + def to_representation(self, value): + return value + + def to_internal_value(self, data): + if isinstance(data, list): + return data + else: + raise ValidationError('Invalid data type.') + + +serializers.ModelSerializer.serializer_field_mapping[MultiStringField] = MultiStringSerializer diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 45fe0b3a70..3e01777e5d 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -17,6 +17,7 @@ from django.utils.timezone import is_naive, make_aware, now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from i18nfield.fields import I18nCharField, I18nTextField +from pretix.base.models import fields from pretix.base.models.base import LoggedModel from pretix.base.models.tax import TaxedPrice @@ -195,6 +196,8 @@ 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 """ event = models.ForeignKey( @@ -329,6 +332,10 @@ 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'] + ) # !!! Attention: If you add new fields here, also add them to the copying code in # pretix/control/forms/item.py if applicable. diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 2a6250df3d..f7d776fdb5 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -94,6 +94,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 """ STATUS_PENDING = "n" @@ -174,6 +176,7 @@ class Order(LockModel, LoggedModel): require_approval = models.BooleanField( default=False ) + sales_channel = models.CharField(max_length=190, default="web") class Meta: verbose_name = _("Order") diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 2cfee01827..62d7d57a93 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -102,7 +102,8 @@ class CartManager: AddOperation: 30 } - def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None): + def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None, + sales_channel='web'): self.event = event self.cart_id = cart_id self.now_dt = now() @@ -115,6 +116,7 @@ class CartManager: self._expiry = None self.invoice_address = invoice_address self._widget_data = widget_data or {} + self._sales_channel = sales_channel @property def positions(self): @@ -192,6 +194,9 @@ class CartManager: if not op.item.is_available() or (op.variation and not op.variation.active): raise CartError(error_messages['unavailable']) + if self._sales_channel not in op.item.sales_channels: + raise CartError(error_messages['unavailable']) + if op.voucher and not op.voucher.applies_to(op.item, op.variation): raise CartError(error_messages['voucher_invalid_item']) @@ -735,7 +740,7 @@ def get_fees(event, request, total, invoice_address, provider): @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en', - invoice_address: int=None, widget_data=None) -> None: + invoice_address: int=None, widget_data=None, sales_channel='web') -> None: """ Adds a list of items to a user's cart. :param event: The event ID in question @@ -755,7 +760,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo try: try: - cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data) + cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data, + sales_channel=sales_channel) cm.add_new_items(items) cm.commit() except LockTimeoutException: @@ -807,7 +813,7 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None: @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en', - invoice_address: int=None) -> None: + invoice_address: int=None, sales_channel='web') -> None: """ Removes a list of items from a user's cart. :param event: The event ID in question @@ -825,7 +831,7 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc pass try: try: - cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia) + cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel) cm.set_addons(addons) cm.commit() except LockTimeoutException: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 0e6b5b0bc4..d7d019b620 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -498,7 +498,7 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None, - meta_info: dict=None): + meta_info: dict=None, sales_channel: str='web'): fees, pf = _get_fees(positions, payment_provider, address, meta_info, event) total = sum([c.price for c in positions]) + sum([c.value for c in fees]) @@ -511,7 +511,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d locale=locale, total=total, meta_info=json.dumps(meta_info or {}), - require_approval=any(p.item.require_approval for p in positions) + require_approval=any(p.item.require_approval for p in positions), + sales_channel=sales_channel ) order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions])) order.save() @@ -550,7 +551,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d def _perform_order(event: str, payment_provider: str, position_ids: List[str], - email: str, locale: str, address: int, meta_info: dict=None): + email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'): event = Event.objects.get(id=event) if payment_provider: @@ -579,7 +580,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], raise OrderError(error_messages['internal']) _check_positions(event, now_dt, positions, address=addr) order = _create_order(event, email, positions, now_dt, pprov, - locale=locale, address=addr, meta_info=meta_info) + locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel) invoice = order.invoices.last() # Might be generated by plugin already if event.settings.get('invoice_generate') == 'True' and invoice_qualified(order): @@ -1318,11 +1319,13 @@ class OrderChangeManager: @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) def perform_order(self, event: str, payment_provider: str, positions: List[str], - email: str=None, locale: str=None, address: int=None, meta_info: dict=None): + email: str=None, locale: str=None, address: int=None, meta_info: dict=None, + sales_channel: str='web'): with language(locale): try: try: - return _perform_order(event, payment_provider, positions, email, locale, address, meta_info) + return _perform_order(event, payment_provider, positions, email, locale, address, meta_info, + sales_channel) except LockTimeoutException: self.retry() except (MaxRetriesExceededError, LockTimeoutException): diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index f48a8420d1..6416c0de38 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -178,6 +178,15 @@ however for this signal, the ``sender`` **may also be None** to allow creating t notification settings! """ +register_sales_channels = django.dispatch.Signal( + providing_args=[] +) +""" +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 +instances. +""" + register_data_exporters = EventPluginSignal( providing_args=[] ) diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 61049a3454..de865148f7 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -8,6 +8,7 @@ from django.utils.translation import ( ) from i18nfield.forms import I18nFormField, I18nTextarea +from pretix.base.channels import get_all_sales_channels from pretix.base.forms import I18nFormSet, I18nModelForm from pretix.base.models import ( Item, ItemCategory, ItemVariation, Question, QuestionOption, Quota, @@ -226,6 +227,10 @@ class ItemCreateForm(I18nModelForm): self.instance.checkin_attention = self.cleaned_data['copy_from'].checkin_attention self.instance.free_price = self.cleaned_data['copy_from'].free_price self.instance.original_price = self.cleaned_data['copy_from'].original_price + self.instance.sales_channels = self.cleaned_data['copy_from'].sales_channels + else: + # Add to all sales channels by default + self.instance.sales_channels = [k for k in get_all_sales_channels().keys()] self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1 instance = super().save(*args, **kwargs) @@ -302,6 +307,13 @@ class ItemUpdateForm(I18nModelForm): 'over 65. This ticket includes access to all parts of the event, except the VIP ' 'area.' ) + self.fields['sales_channels'] = forms.MultipleChoiceField( + label=_('Sales channels'), + choices=( + (c.identifier, c.verbose_name) for c in get_all_sales_channels().values() + ), + widget=forms.CheckboxSelectMultiple + ) change_decimal_field(self.fields['default_price'], self.event.currency) class Meta: @@ -312,6 +324,7 @@ class ItemUpdateForm(I18nModelForm): 'name', 'internal_name', 'active', + 'sales_channels', 'admission', 'description', 'picture', diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 1306a83ab4..0fbd42b08e 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -26,6 +26,7 @@
{% trans "Availability" %} + {% bootstrap_field form.sales_channels layout="control" %} {% bootstrap_field form.available_from layout="control" %} {% bootstrap_field form.available_until layout="control" %} {% bootstrap_field form.max_per_order layout="control" %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index 9fc1a0b0f0..67a6ceef30 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -114,6 +114,10 @@
{{ order.code }}
{% trans "Order date" %}
{{ order.datetime }}
+ {% if sales_channel %} +
{% trans "Sales channel" %}
+
{{ sales_channel.verbose_name }}
+ {% endif %}
{% trans "Order locale" %}
{{ display_locale }} diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 001e50f6a5..71b001b9ab 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -834,7 +834,10 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, UpdateVie def plugin_forms(self): forms = [] for rec, resp in item_forms.send(sender=self.request.event, item=self.item, request=self.request): - forms.append(resp) + if isinstance(resp, (list, tuple)): + forms.extend(resp) + else: + forms.append(resp) return forms def get_success_url(self) -> str: diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 7979e4f75b..f263b6859d 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -26,6 +26,7 @@ from django.views.generic import ( ) from i18nfield.strings import LazyI18nString +from pretix.base.channels import get_all_sales_channels from pretix.base.i18n import language from pretix.base.models import ( CachedCombinedTicket, CachedFile, CachedTicket, Invoice, InvoiceAddress, @@ -160,6 +161,7 @@ class OrderDetail(OrderView): ctx['display_locale'] = dict(settings.LANGUAGES)[self.object.locale or self.request.event.settings.locale] ctx['overpaid'] = self.order.pending_sum * -1 + ctx['sales_channel'] = get_all_sales_channels().get(self.order.sales_channel) return ctx def get_items(self): diff --git a/src/pretix/plugins/ticketoutputpdf/api.py b/src/pretix/plugins/ticketoutputpdf/api.py index 76b2222337..28ebd4a2a6 100644 --- a/src/pretix/plugins/ticketoutputpdf/api.py +++ b/src/pretix/plugins/ticketoutputpdf/api.py @@ -10,7 +10,7 @@ class ItemAssignmentSerializer(I18nAwareModelSerializer): class Meta: model = TicketLayoutItem - fields = ('item',) + fields = ('item', 'sales_channel') class TicketLayoutSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/plugins/ticketoutputpdf/exporters.py b/src/pretix/plugins/ticketoutputpdf/exporters.py index 07765a0062..ca212dca00 100644 --- a/src/pretix/plugins/ticketoutputpdf/exporters.py +++ b/src/pretix/plugins/ticketoutputpdf/exporters.py @@ -85,7 +85,13 @@ class AllTicketsPDF(BaseExporter): with language(op.order.locale): buffer = BytesIO() p = o._create_canvas(buffer) - layout = o.layout_map.get(op.item_id, o.default_layout) + layout = o.layout_map.get( + (op.item_id, op.order.sales_channel), + o.layout_map.get( + (op.item_id, 'web'), + o.default_layout + ) + ) o._draw_page(layout, p, op, op.order) p.save() outbuffer = o._render_with_background(layout, buffer) diff --git a/src/pretix/plugins/ticketoutputpdf/forms.py b/src/pretix/plugins/ticketoutputpdf/forms.py index 5613bc1602..e85667aa91 100644 --- a/src/pretix/plugins/ticketoutputpdf/forms.py +++ b/src/pretix/plugins/ticketoutputpdf/forms.py @@ -19,13 +19,21 @@ class TicketLayoutItemForm(forms.ModelForm): def __init__(self, *args, **kwargs): event = kwargs.pop('event') + self.sales_channel = kwargs.pop('sales_channel') super().__init__(*args, **kwargs) - self.fields['layout'].label = _('PDF ticket layout') - self.fields['layout'].empty_label = _('(Event default)') + if self.sales_channel.identifier != 'web': + self.fields['layout'].label = _('PDF ticket layout for {channel}').format( + channel=self.sales_channel.verbose_name + ) + self.fields['layout'].empty_label = _('(Same as above)') + else: + self.fields['layout'].label = _('PDF ticket layout') + self.fields['layout'].empty_label = _('(Event default)') self.fields['layout'].queryset = event.ticket_layouts.all() self.fields['layout'].required = False def save(self, commit=True): + self.instance.sales_channel = self.sales_channel.identifier if self.cleaned_data['layout'] is None: if self.instance.pk: self.instance.delete() diff --git a/src/pretix/plugins/ticketoutputpdf/migrations/0007_auto_20181123_1059.py b/src/pretix/plugins/ticketoutputpdf/migrations/0007_auto_20181123_1059.py new file mode 100644 index 0000000000..7b4c629061 --- /dev/null +++ b/src/pretix/plugins/ticketoutputpdf/migrations/0007_auto_20181123_1059.py @@ -0,0 +1,29 @@ +# Generated by Django 2.1.1 on 2018-11-23 10:59 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0103_auto_20181121_1224'), + ('ticketoutputpdf', '0006_auto_20181017_0024'), + ] + + operations = [ + migrations.AddField( + model_name='ticketlayoutitem', + name='sales_channel', + field=models.CharField(default='web', max_length=190), + ), + migrations.AlterField( + model_name='ticketlayoutitem', + name='item', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='ticketlayout_assignments', to='pretixbase.Item'), + ), + migrations.AlterUniqueTogether( + name='ticketlayoutitem', + unique_together={('item', 'layout', 'sales_channel')}, + ), + ] diff --git a/src/pretix/plugins/ticketoutputpdf/models.py b/src/pretix/plugins/ticketoutputpdf/models.py index 4310d699e2..c8a9ecc573 100644 --- a/src/pretix/plugins/ticketoutputpdf/models.py +++ b/src/pretix/plugins/ticketoutputpdf/models.py @@ -66,6 +66,10 @@ class TicketLayout(LoggedModel): class TicketLayoutItem(models.Model): - item = models.OneToOneField('pretixbase.Item', null=True, blank=True, related_name='ticketlayout_assignment', - on_delete=models.CASCADE) + item = models.ForeignKey('pretixbase.Item', null=True, blank=True, related_name='ticketlayout_assignments', + on_delete=models.CASCADE) layout = models.ForeignKey('TicketLayout', on_delete=models.CASCADE, related_name='item_assignments') + sales_channel = models.CharField(max_length=190, default='web') + + class Meta: + unique_together = (('item', 'layout', 'sales_channel'),) diff --git a/src/pretix/plugins/ticketoutputpdf/signals.py b/src/pretix/plugins/ticketoutputpdf/signals.py index 551a01bc92..438ec8c5d5 100644 --- a/src/pretix/plugins/ticketoutputpdf/signals.py +++ b/src/pretix/plugins/ticketoutputpdf/signals.py @@ -7,6 +7,7 @@ from django.urls import reverse from django.utils.html import escape from django.utils.translation import ugettext_lazy as _ +from pretix.base.channels import get_all_sales_channels from pretix.base.models import QuestionAnswer from pretix.base.signals import ( # NOQA: legacy import event_copy_data, item_copy_data, layout_text_variables, logentry_display, @@ -61,25 +62,26 @@ def variables_from_questions(sender, *args, **kwargs): @receiver(item_forms, dispatch_uid="pretix_ticketoutputpdf_item_forms") def control_item_forms(sender, request, item, **kwargs): - try: - inst = TicketLayoutItem.objects.get(item=item) - except TicketLayoutItem.DoesNotExist: - inst = TicketLayoutItem(item=item) - return TicketLayoutItemForm( - instance=inst, - event=sender, - data=(request.POST if request.method == "POST" else None), - prefix="ticketlayoutitem" - ) + forms = [] + for k, v in sorted(list(get_all_sales_channels().items()), key=lambda a: (int(a[0] != 'web'), a[0])): + try: + inst = TicketLayoutItem.objects.get(item=item, sales_channel=k) + except TicketLayoutItem.DoesNotExist: + inst = TicketLayoutItem(item=item) + forms.append(TicketLayoutItemForm( + instance=inst, + event=sender, + sales_channel=v, + data=(request.POST if request.method == "POST" else None), + prefix="ticketlayoutitem_{}".format(k) + )) + return forms @receiver(item_copy_data, dispatch_uid="pretix_ticketoutputpdf_item_copy") def copy_item(sender, source, target, **kwargs): - try: - inst = TicketLayoutItem.objects.get(item=source) - TicketLayoutItem.objects.create(item=target, layout=inst.layout) - except TicketLayoutItem.DoesNotExist: - pass + for tli in TicketLayoutItem.objects.filter(item=source): + TicketLayoutItem.objects.create(item=target, layout=tli.layout, sales_channel=tli.sales_channel) @receiver(signal=event_copy_data, dispatch_uid="pretix_ticketoutputpdf_copy_data") @@ -110,7 +112,8 @@ def pdf_event_copy_data_receiver(sender, other, item_map, question_map, **kwargs layout_map[oldid] = bl for bi in TicketLayoutItem.objects.filter(item__event=other): - TicketLayoutItem.objects.create(item=item_map.get(bi.item_id), layout=layout_map.get(bi.layout_id)) + TicketLayoutItem.objects.create(item=item_map.get(bi.item_id), layout=layout_map.get(bi.layout_id), + sales_channel=bi.sales_channel) return layout_map diff --git a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py index 86e427a159..95ecab5252 100644 --- a/src/pretix/plugins/ticketoutputpdf/ticketoutput.py +++ b/src/pretix/plugins/ticketoutputpdf/ticketoutput.py @@ -37,7 +37,7 @@ class PdfTicketOutput(BaseTicketOutput): @cached_property def layout_map(self): return { - bi.item_id: bi.layout + (bi.item_id, bi.sales_channel): bi.layout for bi in TicketLayoutItem.objects.select_related('layout').filter(item__event=self.event) } @@ -68,7 +68,13 @@ class PdfTicketOutput(BaseTicketOutput): buffer = BytesIO() p = self._create_canvas(buffer) - layout = self.layout_map.get(op.item_id, self.default_layout) + layout = self.layout_map.get( + (op.item_id, order.sales_channel), + self.layout_map.get( + (op.item_id, 'web'), + self.default_layout + ) + ) self._draw_page(layout, p, op, order) p.save() outbuffer = self._render_with_background(layout, buffer) @@ -84,7 +90,13 @@ class PdfTicketOutput(BaseTicketOutput): buffer = BytesIO() p = self._create_canvas(buffer) order = op.order - layout = self.layout_map.get(op.item_id, self.default_layout) + layout = self.layout_map.get( + (op.item_id, order.sales_channel), + self.layout_map.get( + (op.item_id, 'web'), + self.default_layout + ) + ) with language(order.locale): self._draw_page(layout, p, op, order) p.save() diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 72ef39fa8c..967a5aa779 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -236,7 +236,8 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): data=(self.request.POST if self.request.method == 'POST' else None), quota_cache=quota_cache, item_cache=item_cache, - subevent=cartpos.subevent + subevent=cartpos.subevent, + sales_channel=self.request.sales_channel ) } @@ -294,7 +295,8 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): return self.get(request, *args, **kwargs) return self.do(self.request.event.id, data, get_or_create_cart_id(self.request), - invoice_address=self.invoice_address.pk, locale=get_language()) + invoice_address=self.invoice_address.pk, locale=get_language(), + sales_channel=request.sales_channel) class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): @@ -613,7 +615,8 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): return self.do(self.request.event.id, self.payment_provider.identifier if self.payment_provider else None, [p.id for p in self.positions], self.cart_session.get('email'), - translation.get_language(), self.invoice_address.pk, meta_info) + translation.get_language(), self.invoice_address.pk, meta_info, + request.sales_channel) def get_success_message(self, value): create_empty_cart_id(self.request) diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index ee8be2dd6e..da010a7e46 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -138,7 +138,6 @@ class AddOnsForm(forms.Form): if override_price: price = override_price - print(price, repr(price), type(price), repr(item.default_price)) if self.price_included: price = TAXED_ZERO else: @@ -191,6 +190,7 @@ class AddOnsForm(forms.Form): quota_cache = kwargs.pop('quota_cache') item_cache = kwargs.pop('item_cache') self.price_included = kwargs.pop('price_included') + self.sales_channel = kwargs.pop('sales_channel') super().__init__(*args, **kwargs) @@ -209,6 +209,7 @@ class AddOnsForm(forms.Form): & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) & Q(hide_without_voucher=False) + & Q(sales_channels__contains=self.sales_channel) ).select_related('tax_rule').prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', diff --git a/src/pretix/presale/utils.py b/src/pretix/presale/utils.py index c0a63b6832..930e13b095 100644 --- a/src/pretix/presale/utils.py +++ b/src/pretix/presale/utils.py @@ -92,6 +92,9 @@ def _detect_event(request, require_live=True, require_plugin=None): if require_plugin not in request.event.get_plugins() and not is_core: raise Http404(_('This feature is not enabled.')) + if not hasattr(request, 'sales_channel'): + # The environ lookup is only relevant during unit testing + request.sales_channel = request.environ.get('PRETIX_SALES_CHANNEL', 'web') for receiver, response in process_request.send(request.event, request=request): if response: return response diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index b975d7426e..ba8d35c526 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -382,7 +382,7 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): items = self._items_from_post_data() if items: return self.do(self.request.event.id, items, cart_id, translation.get_language(), - self.invoice_address.pk, widget_data) + self.invoice_address.pk, widget_data, self.request.sales_channel) else: if 'ajax' in self.request.GET or 'ajax' in self.request.POST: return JsonResponse({ @@ -405,7 +405,7 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, TemplateView): # Fetch all items items, display_add_to_cart = get_grouped_items(self.request.event, self.subevent, - voucher=self.voucher) + voucher=self.voucher, channel=self.request.sales_channel) # Calculate how many options the user still has. If there is only one option, we can # check the box right away ;) diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 07ac3abc94..d7e497073c 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -47,12 +47,13 @@ def item_group_by_category(items): ) -def get_grouped_items(event, subevent=None, voucher=None): +def get_grouped_items(event, subevent=None, voucher=None, channel='web'): items = event.items.all().filter( Q(active=True) & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) & Q(Q(category__isnull=True) | Q(category__is_addon=False)) + & Q(sales_channels__contains=channel) ) vouchq = Q(hide_without_voucher=False) @@ -249,7 +250,8 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView): context = super().get_context_data(**kwargs) if not self.request.event.has_subevents or self.subevent: # Fetch all items - items, display_add_to_cart = get_grouped_items(self.request.event, self.subevent) + items, display_add_to_cart = get_grouped_items(self.request.event, self.subevent, + channel=self.request.sales_channel) context['itemnum'] = len(items) # Regroup those by category diff --git a/src/pretix/presale/views/widget.py b/src/pretix/presale/views/widget.py index 69377cb562..ebfbea0a76 100644 --- a/src/pretix/presale/views/widget.py +++ b/src/pretix/presale/views/widget.py @@ -155,7 +155,7 @@ class WidgetAPIProductList(View): def _get_items(self): items, display_add_to_cart = get_grouped_items( - self.request.event, subevent=self.subevent, voucher=self.voucher + self.request.event, subevent=self.subevent, voucher=self.voucher, channel='web' ) grps = [] for cat, g in item_group_by_category(items): diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index ab7a1aa7a8..cdc1ba1666 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -212,6 +212,7 @@ TEST_ITEM_RES = { "name": {"en": "Budget Ticket"}, "internal_name": None, "default_price": "23.00", + "sales_channels": ["web"], "category": None, "active": True, "description": None, @@ -352,6 +353,7 @@ def test_item_create(token_client, organizer, event, item, category, taxrule): "en": "Ticket" }, "active": True, + "sales_channels": ["web", "pretixpos"], "description": None, "default_price": "23.00", "free_price": False, @@ -373,6 +375,7 @@ def test_item_create(token_client, organizer, event, item, category, taxrule): format='json' ) assert resp.status_code == 201 + assert Item.objects.get(pk=resp.data['id']).sales_channels == ["web", "pretixpos"] @pytest.mark.django_db diff --git a/src/tests/api/test_orders.py b/src/tests/api/test_orders.py index e92df79e27..c157d5f469 100644 --- a/src/tests/api/test_orders.py +++ b/src/tests/api/test_orders.py @@ -177,6 +177,7 @@ TEST_ORDER_RES = { "datetime": "2017-12-01T10:00:00Z", "expires": "2017-12-10T10:00:00Z", "payment_date": "2017-12-01", + "sales_channel": "web", "fees": [ { "fee_type": "payment", @@ -1238,6 +1239,7 @@ def test_order_invalid_state_deny(token_client, organizer, event, order): ORDER_CREATE_PAYLOAD = { "email": "dummy@dummy.test", "locale": "en", + "sales_channel": "web", "fees": [ { "fee_type": "payment", @@ -1297,6 +1299,7 @@ def test_order_create(token_client, organizer, event, item, quota, question): assert o.locale == "en" assert o.total == Decimal('23.25') assert o.status == Order.STATUS_PENDING + assert o.sales_channel == "web" p = o.payments.first() assert p.provider == "banktransfer" @@ -1337,6 +1340,37 @@ def test_order_create_invoice_address_optional(token_client, organizer, event, i o.invoice_address +@pytest.mark.django_db +def test_order_create_sales_channel_optional(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + del res['sales_channel'] + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 201 + o = Order.objects.get(code=resp.data['code']) + assert o.sales_channel == "web" + + +@pytest.mark.django_db +def test_order_create_sales_channel_invalid(token_client, organizer, event, item, quota, question): + res = copy.deepcopy(ORDER_CREATE_PAYLOAD) + res['positions'][0]['item'] = item.pk + res['positions'][0]['answers'][0]['question'] = question.pk + res['sales_channel'] = 'foo' + resp = token_client.post( + '/api/v1/organizers/{}/events/{}/orders/'.format( + organizer.slug, event.slug + ), format='json', data=res + ) + assert resp.status_code == 400 + assert resp.data == {'sales_channel': ['Unknown sales channel.']} + + @pytest.mark.django_db def test_order_create_attendee_name_optional(token_client, organizer, event, item, quota, question): res = copy.deepcopy(ORDER_CREATE_PAYLOAD) diff --git a/src/tests/control/test_items.py b/src/tests/control/test_items.py index bb7add5265..8424110ec8 100644 --- a/src/tests/control/test_items.py +++ b/src/tests/control/test_items.py @@ -304,7 +304,8 @@ class ItemsTest(ItemFormTest): 'default_price': '23.00', 'tax_rate': '19.00', 'active': 'yes', - 'allow_cancel': 'yes' + 'allow_cancel': 'yes', + 'sales_channels': 'web' }) self.item1.refresh_from_db() assert self.item1.default_price == Decimal('23.00') @@ -425,6 +426,7 @@ class ItemsTest(ItemFormTest): def test_create_copy(self): q = Question.objects.create(event=self.event1, question="Size", type="N") q.items.add(self.item2) + self.item2.sales_channels = ["web", "bar"] self.client.post('/control/event/%s/%s/items/add' % (self.orga1.slug, self.event1.slug), { 'name_0': 'Intermediate', @@ -443,6 +445,7 @@ class ItemsTest(ItemFormTest): assert i_new.require_voucher == i_old.require_voucher assert i_new.hide_without_voucher == i_old.hide_without_voucher assert i_new.allow_cancel == i_old.allow_cancel + assert i_new.sales_channels == i_old.sales_channels assert set(i_new.questions.all()) == set(i_old.questions.all()) assert set([str(v.value) for v in i_new.variations.all()]) == set([str(v.value) for v in i_old.variations.all()]) diff --git a/src/tests/plugins/badges/test_control.py b/src/tests/plugins/badges/test_control.py index 5624c38188..796de82a09 100644 --- a/src/tests/plugins/badges/test_control.py +++ b/src/tests/plugins/badges/test_control.py @@ -66,7 +66,8 @@ class BadgeLayoutFormTest(SoupTest): 'tax_rate': '19.00', 'active': 'yes', 'allow_cancel': 'yes', - 'badgeitem-layout': bl2.pk + 'badgeitem-layout': bl2.pk, + 'sales_channels': 'web', }) assert BadgeItem.objects.get(item=self.item1, layout=bl2) self.client.post('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item1.id), { @@ -75,6 +76,7 @@ class BadgeLayoutFormTest(SoupTest): 'tax_rate': '19.00', 'active': 'yes', 'allow_cancel': 'yes', + 'sales_channels': 'web', }) assert not BadgeItem.objects.filter(item=self.item1, layout=bl2).exists() diff --git a/src/tests/plugins/ticketoutputpdf/test_api.py b/src/tests/plugins/ticketoutputpdf/test_api.py index 4d51720ea3..68c65dee58 100644 --- a/src/tests/plugins/ticketoutputpdf/test_api.py +++ b/src/tests/plugins/ticketoutputpdf/test_api.py @@ -29,7 +29,7 @@ RES_LAYOUT = { 'id': 1, 'name': 'Foo', 'default': True, - 'item_assignments': [{'item': 1}], + 'item_assignments': [{'item': 1, 'sales_channel': 'web'}], 'layout': [{'a': 2}], 'background': None } diff --git a/src/tests/plugins/ticketoutputpdf/test_control.py b/src/tests/plugins/ticketoutputpdf/test_control.py index 9dd8bcd633..ce908969be 100644 --- a/src/tests/plugins/ticketoutputpdf/test_control.py +++ b/src/tests/plugins/ticketoutputpdf/test_control.py @@ -66,7 +66,8 @@ class TicketLayoutFormTest(SoupTest): 'tax_rate': '19.00', 'active': 'yes', 'allow_cancel': 'yes', - 'ticketlayoutitem-layout': bl2.pk + 'ticketlayoutitem_web-layout': bl2.pk, + 'sales_channels': 'web', }) assert TicketLayoutItem.objects.get(item=self.item1, layout=bl2) self.client.post('/control/event/%s/%s/items/%d/' % (self.orga1.slug, self.event1.slug, self.item1.id), { @@ -75,6 +76,7 @@ class TicketLayoutFormTest(SoupTest): 'tax_rate': '19.00', 'active': 'yes', 'allow_cancel': 'yes', + 'sales_channels': 'web', }) assert not TicketLayoutItem.objects.filter(item=self.item1, layout=bl2).exists() diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 44ba27a5ef..c0c316fea1 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -658,6 +658,22 @@ class CartTest(CartTestMixin, TestCase): self.assertIn('no longer available', doc.select('.alert-danger')[0].text) self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists()) + def test_wrong_sales_channel(self): + self.ticket.sales_channels = ['bar'] + self.ticket.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + }, follow=True) + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0) + + def test_other_sales_channel(self): + self.ticket.sales_channels = ['bar'] + self.ticket.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + }, follow=True, PRETIX_SALES_CHANNEL='bar') + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1) + def test_in_time_available(self): self.ticket.available_until = now() + timedelta(days=2) self.ticket.available_from = now() - timedelta(days=2) diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 7b25eb7966..d40e7387c9 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -84,7 +84,7 @@ class ItemDisplayTest(EventTestMixin, SoupTest): q = Quota.objects.create(event=self.event, name='Quota', size=2) item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=False) q.items.add(item) - html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content self.assertNotIn("Early-bird", html) self.assertNotIn("btn-add-to-cart", html) @@ -96,6 +96,16 @@ class ItemDisplayTest(EventTestMixin, SoupTest): self.assertIn("Early-bird", doc.select("section .product-row")[0].text) self.assertEqual(len(doc.select("#btn-add-to-cart")), 1) + def test_sales_channel(self): + q = Quota.objects.create(event=self.event, name='Quota', size=2) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True, + sales_channels=['bar']) + q.items.add(item) + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content + self.assertNotIn("Early-bird", html) + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug), PRETIX_SALES_CHANNEL="bar").rendered_content + self.assertIn("Early-bird", html) + def test_timely_available(self): q = Quota.objects.create(event=self.event, name='Quota', size=2) item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True, @@ -110,7 +120,7 @@ class ItemDisplayTest(EventTestMixin, SoupTest): item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True, available_until=now() - datetime.timedelta(days=2)) q.items.add(item) - html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content self.assertNotIn("Early-bird", html) def test_not_yet_available(self): @@ -118,7 +128,7 @@ class ItemDisplayTest(EventTestMixin, SoupTest): item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True, available_from=now() + datetime.timedelta(days=2)) q.items.add(item) - html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content self.assertNotIn("Early-bird", html) def test_hidden_without_voucher(self): @@ -126,7 +136,7 @@ class ItemDisplayTest(EventTestMixin, SoupTest): item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=True, hide_without_voucher=True) q.items.add(item) - html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + html = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)).rendered_content self.assertNotIn("Early-bird", html) def test_simple_with_category(self):