From 17adde99fad9104ff6756cf301ce0dba77917a37 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 15 Sep 2021 12:04:17 +0200 Subject: [PATCH] Allow to restrict availability of variations by date, sales channel, and voucher (#2202) --- doc/api/resources/item_variations.rst | 32 ++++ doc/api/resources/items.rst | 52 +++++++ src/pretix/api/serializers/item.py | 6 +- .../migrations/0197_auto_20210914_0814.py | 36 +++++ src/pretix/base/models/event.py | 4 + src/pretix/base/models/items.py | 48 +++++- src/pretix/base/services/cart.py | 9 +- src/pretix/base/services/orders.py | 4 +- src/pretix/base/timeline.py | 34 +++++ src/pretix/control/forms/item.py | 25 +++- .../control/templates/pretixcontrol/base.html | 1 + .../item/include_variations.html | 137 +++++++++++++----- src/pretix/control/views/item.py | 1 + src/pretix/presale/views/event.py | 13 +- src/pretix/static/pretixbase/js/details.js | 2 +- .../static/pretixcontrol/js/ui/variations.js | 58 ++++++++ .../static/pretixcontrol/scss/_forms.scss | 25 +++- src/tests/api/test_items.py | 16 ++ src/tests/presale/test_cart.py | 84 +++++++++++ src/tests/presale/test_event.py | 48 ++++++ 20 files changed, 583 insertions(+), 52 deletions(-) create mode 100644 src/pretix/base/migrations/0197_auto_20210914_0814.py create mode 100644 src/pretix/static/pretixcontrol/js/ui/variations.js diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index 0a31ab72b7..037ffd98b2 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -26,6 +26,18 @@ description multi-lingual string A public descri position integer An integer, used for sorting require_membership boolean If ``true``, booking this variation requires an active 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. + The item-level list takes precedence, i.e. a sales + channel needs to be on both lists for the item to be + available. +available_from datetime The first date time at which this variation can be bought + (or ``null``). +available_until datetime The last date time at which this variation can be bought + (or ``null``). +hide_without_voucher boolean If ``true``, this variation is only shown during the voucher + redemption process, but not in the normal shop + frontend. ===================================== ========================== ======================================================= Endpoints @@ -64,6 +76,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": { "en": "Test2" }, @@ -129,6 +145,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 0 } @@ -160,6 +180,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 0 } @@ -181,6 +205,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 0 } @@ -233,6 +261,10 @@ Endpoints "active": false, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 1 } diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index be6dc20ff5..9251ddcefa 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -107,6 +107,18 @@ variations list of objects A list with one ├ require_membership boolean If ``true``, booking this variation requires an active 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. + The item-level list takes precedence, i.e. a sales + channel needs to be on both lists for the item to be + available. +├ available_from datetime The first date time at which this variation can be bought + (or ``null``). +├ available_until datetime The last date time at which this variation can be bought + (or ``null``). +├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher + redemption process, but not in the normal shop + frontend. └ position integer An integer, used for sorting addons list of objects Definition of add-ons that can be chosen for this item. Only writable during creation, @@ -230,6 +242,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 0 }, @@ -241,6 +257,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 1 } @@ -337,6 +357,10 @@ Endpoints "require_membership": false, "require_membership_types": [], "description": null, + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "position": 0 }, { @@ -347,6 +371,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 1 } @@ -422,6 +450,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 0 }, @@ -433,6 +465,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 1 } @@ -497,6 +533,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 0 }, @@ -508,6 +548,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 1 } @@ -603,6 +647,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 0 }, @@ -614,6 +662,10 @@ Endpoints "active": true, "require_membership": false, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": null, + "available_until": null, + "hide_without_voucher": false, "description": null, "position": 1 } diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index f46b450410..c6b07cbb6d 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -59,7 +59,8 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): model = ItemVariation fields = ('id', 'value', 'active', 'description', 'position', 'default_price', 'price', 'original_price', - 'require_membership', 'require_membership_types',) + 'require_membership', 'require_membership_types', 'available_from', 'available_until', + 'sales_channels', 'hide_without_voucher',) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -74,7 +75,8 @@ class ItemVariationSerializer(I18nAwareModelSerializer): model = ItemVariation fields = ('id', 'value', 'active', 'description', 'position', 'default_price', 'price', 'original_price', - 'require_membership', 'require_membership_types',) + 'require_membership', 'require_membership_types', 'available_from', 'available_until', + 'sales_channels', 'hide_without_voucher',) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) diff --git a/src/pretix/base/migrations/0197_auto_20210914_0814.py b/src/pretix/base/migrations/0197_auto_20210914_0814.py new file mode 100644 index 0000000000..ee7660bd2b --- /dev/null +++ b/src/pretix/base/migrations/0197_auto_20210914_0814.py @@ -0,0 +1,36 @@ +# Generated by Django 3.2.4 on 2021-09-14 08:14 + +from django.db import migrations, models + +import pretix.base.models.fields +import pretix.base.models.items + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0196_auto_20210523_1322'), + ] + + operations = [ + migrations.AddField( + model_name='itemvariation', + name='available_from', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='itemvariation', + name='available_until', + field=models.DateTimeField(blank=True, null=True), + ), + migrations.AddField( + model_name='itemvariation', + name='hide_without_voucher', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='itemvariation', + name='sales_channels', + field=pretix.base.models.fields.MultiStringField(default=pretix.base.models.items._all_sales_channels_identifiers), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index ab6afbede6..befd5cbca9 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -268,6 +268,10 @@ class EventMixin: ).values('items') sq_active_variation = ItemVariation.objects.filter( Q(active=True) + & Q(sales_channels__contains=channel) + & Q(hide_without_voucher=False) + & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) + & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) & Q(item__active=True) & Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=now())) & Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=now())) diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 0c7dd9fd53..0425c96867 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -736,6 +736,11 @@ class Item(LoggedModel): return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0])) +def _all_sales_channels_identifiers(): + from pretix.base.channels import get_all_sales_channels + return list(get_all_sales_channels().keys()) + + class ItemVariation(models.Model): """ A variation of a product. For example, if your item is 'T-Shirt' @@ -761,7 +766,7 @@ class ItemVariation(models.Model): ) value = I18nCharField( max_length=255, - verbose_name=_('Description') + verbose_name=_('Variation') ) active = models.BooleanField( default=True, @@ -797,6 +802,29 @@ class ItemVariation(models.Model): verbose_name=_('Membership types'), blank=True, ) + available_from = models.DateTimeField( + verbose_name=_("Available from"), + null=True, blank=True, + help_text=_('This variation will not be sold before the given date.') + ) + available_until = models.DateTimeField( + verbose_name=_("Available until"), + null=True, blank=True, + help_text=_('This variation will not be sold after the given date.') + ) + sales_channels = fields.MultiStringField( + verbose_name=_('Sales channels'), + default=_all_sales_channels_identifiers, + 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, + ) + hide_without_voucher = models.BooleanField( + verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'), + default=False, + help_text=_('This variation will be hidden from the event page until the user enters a voucher ' + 'that unlocks this variation.') + ) objects = ScopedManager(organizer='item__event__organizer') @@ -928,6 +956,24 @@ class ItemVariation(models.Model): def is_only_variation(self): return ItemVariation.objects.filter(item=self.item).count() == 1 + def is_available_by_time(self, now_dt: datetime=None) -> bool: + now_dt = now_dt or now() + if self.available_from and self.available_from > now_dt: + return False + if self.available_until and self.available_until < now_dt: + return False + return True + + def is_available(self, now_dt: datetime=None) -> bool: + """ + Returns whether this item is available according to its ``active`` flag + and its ``available_from`` and ``available_until`` fields + """ + now_dt = now_dt or now() + if not self.active or not self.is_available_by_time(now_dt): + return False + return True + class ItemAddOn(models.Model): """ diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 75d69c26b3..4e06988947 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -283,13 +283,16 @@ class CartManager: if op.item.require_voucher and op.voucher is None: raise CartError(error_messages['voucher_required']) - if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items): + if ( + (op.item.hide_without_voucher or (op.variation and op.variation.hide_without_voucher)) and + (op.voucher is None or not op.voucher.show_hidden_items) + ): raise CartError(error_messages['voucher_required']) - if not op.item.is_available() or (op.variation and not op.variation.active): + if not op.item.is_available() or (op.variation and not op.variation.is_available()): raise CartError(error_messages['unavailable']) - if self._sales_channel not in op.item.sales_channels: + 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 op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available(): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 5e5e462f4e..762de7a289 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -572,7 +572,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio if cp.pk in deleted_positions: continue - if not cp.item.is_available() or (cp.variation and not cp.variation.active): + if not cp.item.is_available() or (cp.variation and not cp.variation.is_available()): err = err or error_messages['unavailable'] delete(cp) continue @@ -644,7 +644,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio err = err or error_messages['voucher_required'] break - if cp.item.hide_without_voucher and ( + if (cp.item.hide_without_voucher or (cp.variation and cp.variation.hide_without_voucher)) and ( cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation) ) and not cp.is_bundled: delete(cp) diff --git a/src/pretix/base/timeline.py b/src/pretix/base/timeline.py index f4993fb68a..d82e2b87f7 100644 --- a/src/pretix/base/timeline.py +++ b/src/pretix/base/timeline.py @@ -27,6 +27,7 @@ from django.urls import reverse from django.utils.timezone import make_aware from django.utils.translation import pgettext_lazy +from pretix.base.models import ItemVariation from pretix.base.reldate import RelativeDateWrapper from pretix.base.signals import timeline_events @@ -240,6 +241,39 @@ def timeline_for_event(event, subevent=None): }) )) + for v in ItemVariation.objects.filter( + Q(available_from__isnull=False) | Q(available_until__isnull=False), + item__event=event + ).select_related('item'): + if v.available_from: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=v.available_from, + description=pgettext_lazy('timeline', 'Product variation "{product} – {variation}" becomes available').format( + product=str(v.item), + variation=str(v.value), + ), + edit_url=reverse('control:event.item', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'item': v.item.pk, + }) + )) + if v.available_until: + tl.append(TimelineEvent( + event=event, subevent=subevent, + datetime=v.available_until, + description=pgettext_lazy('timeline', 'Product variation "{product} – {variation}" becomes unavailable').format( + product=str(v.item), + variation=str(v.value), + ), + edit_url=reverse('control:event.item', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'item': v.item.pk, + }) + )) + pprovs = event.get_payment_providers() # This is a special case, depending on payment providers not overriding BasePaymentProvider by too much, but it's # preferrable to having all plugins implement this spearately. diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index bdc5e34a96..c3a42b027e 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -683,7 +683,20 @@ 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['description'].widget.attrs['rows'] = 3 if qs: self.fields['require_membership_types'].queryset = qs else: @@ -700,9 +713,19 @@ class ItemVariationForm(I18nModelForm): 'original_price', 'description', 'require_membership', - 'require_membership_types' + 'require_membership_types', + 'available_from', + 'available_until', + 'sales_channels', + 'hide_without_voucher', ] + field_classes = { + 'available_from': SplitDateTimeField, + 'available_until': SplitDateTimeField, + } widgets = { + 'available_from': SplitDateTimePickerWidget(), + 'available_until': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_available_from_0'}), 'require_membership_types': forms.CheckboxSelectMultiple(attrs={ 'class': 'scrolling-multiple-choice' }), diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 5e3e455186..741c54b008 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -43,6 +43,7 @@ + diff --git a/src/pretix/control/templates/pretixcontrol/item/include_variations.html b/src/pretix/control/templates/pretixcontrol/item/include_variations.html index 6fafe9cffe..befeda1942 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_variations.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_variations.html @@ -1,37 +1,59 @@ {% load i18n %} {% load bootstrap3 %} {% load formset_tags %} -
+
{{ formset.management_form }} {% bootstrap_formset_errors formset %}
{% for form in formset %} -
+
{{ form.id }} {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} {% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
-
-

-
-
- {% bootstrap_field form.value layout='inline' form_group_class="" %} -
-
- - - - {% if form.instance.id %} -
#{{ form.instance.id }} - {% endif %} -
+ +
+
+ + + + + Variation name + + + + {% if form.instance.id %} +
+ #{{ form.instance.id }} + {% endif %}
-

-
+
+ + + +
+
+ {% for k, c in sales_channels.items %} + + {% endfor %} +
+
+ +
+
+ + + +
+
+
{% if form.instance.pk and not form.instance.quotas.exists %}
@@ -43,9 +65,14 @@ {% endif %} {% bootstrap_form_errors form %} {% bootstrap_field form.active layout="control" %} + {% bootstrap_field form.value layout="control" %} {% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %} {% bootstrap_field form.description layout="control" %} + {% bootstrap_field form.available_from layout="control" %} + {% bootstrap_field form.available_until layout="control" %} + {% bootstrap_field form.sales_channels layout="control" %} + {% bootstrap_field form.hide_without_voucher layout="control" %} {% if form.require_membership %} {% bootstrap_field form.require_membership layout="control" %}
@@ -53,39 +80,69 @@
{% endif %}
-
+ {% endfor %}

diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 6c49069b2d..12a05d1f11 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -1331,6 +1331,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE "Your participants won't be able to buy the bundle unless you remove this " "item from it.")) + ctx['sales_channels'] = get_all_sales_channels() return ctx @cached_property diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 3a292a6e1e..ffd7ab4d01 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -113,6 +113,13 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require if not event.settings.seating_choice: requires_seat = Value(0, output_field=IntegerField()) + variation_q = ( + Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) & + Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) + ) + if not voucher or not voucher.show_hidden_items: + variation_q &= Q(hide_without_voucher=False) + items = base_qs.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher, allow_addons=allow_addons).select_related( 'category', 'tax_rule', # for re-grouping 'hidden_if_available', @@ -147,7 +154,11 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require ) ), ).filter( - active=True, quotas__isnull=False, subevent_disabled=False + variation_q, + active=True, + sales_channels__contains=channel, + quotas__isnull=False, + subevent_disabled=False ).prefetch_related( Prefetch('quotas', to_attr='_subevent_quotas', diff --git a/src/pretix/static/pretixbase/js/details.js b/src/pretix/static/pretixbase/js/details.js index eadd8a4a5b..69b208be00 100644 --- a/src/pretix/static/pretixbase/js/details.js +++ b/src/pretix/static/pretixbase/js/details.js @@ -3,7 +3,7 @@ setup_collapsible_details = function (el) { var isOpera = Object.prototype.toString.call(window.opera) == '[object Opera]'; el.find("details summary, details summary a[data-toggle=variations]").click(function (e) { - if (this.tagName !== "A" && $(e.target).closest("a").length > 0) { + if (this.tagName !== "A" && $(e.target).closest("a, button").length > 0) { return true; } var $details = $(this).closest("details"); diff --git a/src/pretix/static/pretixcontrol/js/ui/variations.js b/src/pretix/static/pretixcontrol/js/ui/variations.js new file mode 100644 index 0000000000..0b0f20d77d --- /dev/null +++ b/src/pretix/static/pretixcontrol/js/ui/variations.js @@ -0,0 +1,58 @@ +/*global $, Morris, gettext*/ +$(function () { + // Question view + if (!$("#item_variations").length) { + return; + } + + function update_variation_summary($el) { + var var_name = $el.find("input[name*=-value_]").filter(function () {return !!this.value}).first().val(); + var price = $el.find("input[name*=-default_price]").val(); + + + $el.find(".variation-name").text(var_name); + $el.find(".variation-price").text(price); + $el.find(".variation-timeframe").toggleClass("variation-icon-hidden", !( + !!$el.find("input[name$=-available_from_0]").val() || + !!$el.find("input[name$=-available_until_0]").val() + )); + $el.find(".variation-name").toggleClass("variation-disabled", !( + !!$el.find("input[name$=-active]").prop("checked") + )); + $el.find(".variation-voucher").toggleClass("variation-icon-hidden", !( + !!$el.find("input[name$=-hide_without_voucher]").prop("checked") + )); + $el.find(".variation-membership").toggleClass("variation-icon-hidden", !( + !!$el.find("input[name$=-require_membership]").prop("checked") + )); + $el.find(".variation-warning").toggleClass("hidden", !( + $el.find(".alert-warning").length + )); + $el.find(".variation-error").toggleClass("hidden", !( + $el.find(".alert-danger, .has-error").length + )); + $el.find("input[name$=-sales_channels]").each(function () { + $el.find(".variation-channel-" + $(this).val()).toggleClass("variation-icon-hidden", !( + $(this).prop("checked") && $("input[name=sales_channels][value=" + $(this).val() + "]").prop("checked") + )); + }) + } + + $("#item_variations [data-formset-form]").each(function () { + var $el = $(this); + update_variation_summary($el); + $(this).on("change dp.change", "input", function () {update_variation_summary($el)}); + }); + $("input[name=sales_channels]").on("change", function() { + $("#item_variations [data-formset-form]").each(function () { + update_variation_summary($(this)); + }); + }); + $("#item_variations").on("formAdded", "details", function (event) { + console.log("added", event.target) + var $el = $(event.target); + update_variation_summary($el); + $(this).on("change dp.change", "input", function () {update_variation_summary($el)}); + setup_collapsible_details($("#item_variations")); + }); +}); diff --git a/src/pretix/static/pretixcontrol/scss/_forms.scss b/src/pretix/static/pretixcontrol/scss/_forms.scss index 9a939d0d37..8f742938cb 100644 --- a/src/pretix/static/pretixcontrol/scss/_forms.scss +++ b/src/pretix/static/pretixcontrol/scss/_forms.scss @@ -46,7 +46,7 @@ td > .form-group > .checkbox { @include box-shadow(none); } -div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], div[data-nested-formset-body] { +div[data-formset-body], div[data-formset-form], div[data-nested-formset-form], div[data-nested-formset-body], details[data-formset-form] { width: 100%; } @@ -813,3 +813,26 @@ table td > .checkbox input[type="checkbox"] { font-weight: normal; } } + +details { + summary .chevron::before { + content: $fa-var-caret-right; + } + &[open] .chevron::before { + content: $fa-var-caret-down; + } +} + +#item_variations { + summary small { + display: inline-block; + margin-left: 2.1em; + } + .variation-icon-hidden { + opacity: 0; + pointer-events: none; + } + .variation-disabled { + text-decoration: line-through; + } +} diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 5b7861cc77..fead27ca89 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -44,6 +44,7 @@ from django_countries.fields import Country from django_scopes import scopes_disabled from pytz import UTC +from pretix.base.channels import get_all_sales_channels from pretix.base.models import ( CartPosition, InvoiceAddress, Item, ItemAddOn, ItemBundle, ItemCategory, ItemVariation, Order, OrderPosition, Question, QuestionOption, Quota, @@ -376,6 +377,10 @@ def test_item_detail_variations(token_client, organizer, event, team, item): "position": 0, "require_membership": False, "require_membership_types": [], + "sales_channels": list(get_all_sales_channels().keys()), + "available_from": None, + "available_until": None, + "hide_without_voucher": False, "original_price": None }] res["has_variations"] = True @@ -517,6 +522,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego new_item = Item.objects.get(pk=resp.data['id']) assert new_item.variations.first().value.localize('de') == "Kommentar" assert new_item.variations.first().value.localize('en') == "Comment" + assert set(new_item.variations.first().sales_channels) == set(get_all_sales_channels().keys()) @pytest.mark.django_db @@ -1205,6 +1211,10 @@ TEST_VARIATIONS_RES = { "price": "23.00", "require_membership": False, "require_membership_types": [], + "sales_channels": list(get_all_sales_channels().keys()), + "available_from": None, + "available_until": None, + "hide_without_voucher": False, "original_price": None } @@ -1218,6 +1228,10 @@ TEST_VARIATIONS_UPDATE = { "default_price": "20.0", "require_membership": False, "require_membership_types": [], + "sales_channels": ["web"], + "available_from": None, + "available_until": None, + "hide_without_voucher": False, "original_price": None } @@ -1264,6 +1278,7 @@ def test_variations_create(token_client, organizer, event, item, variation): var = ItemVariation.objects.get(pk=resp.data['id']) assert var.position == 1 assert var.price == 23.0 + assert set(var.sales_channels) == set(get_all_sales_channels().keys()) @pytest.mark.django_db @@ -1302,6 +1317,7 @@ def test_variations_update(token_client, organizer, event, item, item3, variatio "en": "ChildC2" }, "position": 1, + "sales_channels": ["web"], "default_price": "20.00", "original_price": "50.00" }, diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 16eab8353c..0d45292788 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -880,6 +880,24 @@ class CartTest(CartTestMixin, TestCase): with scopes_disabled(): self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0) + def test_variation_wrong_sales_channel(self): + self.shirt_blue.sales_channels = ['bar'] + self.shirt_blue.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1', + }, follow=True) + with scopes_disabled(): + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0) + self.shirt_blue.sales_channels = ['bar', 'web'] + self.shirt_blue.save() + self.shirt.sales_channels = ['bar'] + self.shirt.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1', + }, follow=True) + with scopes_disabled(): + 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() @@ -965,6 +983,34 @@ class CartTest(CartTestMixin, TestCase): with scopes_disabled(): self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0) + def test_variation_in_time_available(self): + self.shirt_blue.available_until = now() + timedelta(days=2) + self.shirt_blue.available_from = now() - timedelta(days=2) + self.shirt_blue.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1', + }, follow=True) + with scopes_disabled(): + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 1) + + def test_variation_no_longer_available(self): + self.shirt_blue.available_until = now() - timedelta(days=2) + self.shirt_blue.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1', + }, follow=True) + with scopes_disabled(): + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0) + + def test_variation_not_yet_available(self): + self.shirt_blue.available_from = now() + timedelta(days=2) + self.shirt_blue.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_blue.id): '1', + }, follow=True) + with scopes_disabled(): + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count(), 0) + def test_max_per_item_failed(self): self.ticket.max_per_order = 2 self.ticket.save() @@ -1751,6 +1797,44 @@ class CartTest(CartTestMixin, TestCase): objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 0) + def test_variation_hide_without_voucher(self): + with scopes_disabled(): + v = Voucher.objects.create(item=self.shirt, event=self.event) + self.shirt_red.hide_without_voucher = True + self.shirt_red.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', + '_voucher_code': v.code + }, follow=True) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.shirt) + self.assertEqual(objs[0].variation, self.shirt_red) + + def test_variation_hide_without_voucher_failed_because_of_voucher(self): + with scopes_disabled(): + v = Voucher.objects.create(item=self.shirt, event=self.event, show_hidden_items=False) + self.shirt_red.hide_without_voucher = True + self.shirt_red.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', + '_voucher_code': v.code + }, follow=True) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + + def test_variation_hide_without_voucher_failed(self): + self.shirt_red.hide_without_voucher = True + self.shirt_red.save() + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1', + }, follow=True) + with scopes_disabled(): + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_voucher_multiuse_ok(self): with scopes_disabled(): v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 6f74f6210c..b8f798b047 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -455,6 +455,54 @@ class ItemDisplayTest(EventTestMixin, SoupTest): q.variations.add(var1) self._assert_variation_found() + def test_variation_available_from(self): + with scopes_disabled(): + c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) + q = Quota.objects.create(event=self.event, name='Quota', size=None) + item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0) + var1 = ItemVariation.objects.create(item=item, value='Red', available_from=now() - datetime.timedelta(days=1)) + var2 = ItemVariation.objects.create(item=item, value='Blue', available_from=now() + datetime.timedelta(days=1)) + q.items.add(item) + q.variations.add(var1) + q.variations.add(var2) + self._assert_variation_found() + + def test_variation_available_until(self): + with scopes_disabled(): + c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) + q = Quota.objects.create(event=self.event, name='Quota', size=None) + item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0) + var1 = ItemVariation.objects.create(item=item, value='Red', available_until=now() + datetime.timedelta(days=1)) + var2 = ItemVariation.objects.create(item=item, value='Blue', available_until=now() - datetime.timedelta(days=1)) + q.items.add(item) + q.variations.add(var1) + q.variations.add(var2) + self._assert_variation_found() + + def test_variation_hide_without_voucher(self): + with scopes_disabled(): + c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) + q = Quota.objects.create(event=self.event, name='Quota', size=None) + item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0) + var1 = ItemVariation.objects.create(item=item, value='Red') + var2 = ItemVariation.objects.create(item=item, value='Blue', hide_without_voucher=True) + q.items.add(item) + q.variations.add(var1) + q.variations.add(var2) + self._assert_variation_found() + + def test_variation_sales_channel(self): + with scopes_disabled(): + c = ItemCategory.objects.create(event=self.event, name="Entry tickets", position=0) + q = Quota.objects.create(event=self.event, name='Quota', size=None) + item = Item.objects.create(event=self.event, name='Early-bird ticket', category=c, default_price=0) + var1 = ItemVariation.objects.create(item=item, value='Red') + var2 = ItemVariation.objects.create(item=item, value='Blue', sales_channels=['foobar']) + q.items.add(item) + q.variations.add(var1) + q.variations.add(var2) + self._assert_variation_found() + def _assert_variation_found(self): doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) self.assertIn("Early-bird", doc.select("section:nth-of-type(1) div:nth-of-type(1)")[0].text)