diff --git a/doc/api/resources/items.rst b/doc/api/resources/items.rst index 980d3d5e34..d1112aed95 100644 --- a/doc/api/resources/items.rst +++ b/doc/api/resources/items.rst @@ -52,9 +52,12 @@ available_from datetime The first dat (or ``null``). available_until datetime The last date time at which this item can be bought (or ``null``). -hidden_if_available integer The internal ID of a quota object, or ``null``. If +hidden_if_available integer **DEPRECATED** The internal ID of a quota object, or ``null``. If set, this item won't be shown publicly as long as this quota is available. +hidden_if_item_available integer The internal ID of a different item, or ``null``. If + set, this item won't be shown publicly as long as this + other item is available. require_voucher boolean If ``true``, this item can only be bought using a voucher that is specifically assigned to this item. hide_without_voucher boolean If ``true``, this item is only shown during the voucher @@ -204,6 +207,11 @@ meta_data object Values set fo The ``free_price_suggestion`` and ``variations[x].free_price_suggestion`` attributes have been added. +.. versionchanged:: 2023.10 + + The ``hidden_if_item_available`` attributes has been added, the ``hidden_if_available`` attribute has been + deprecated. + Notes ----- @@ -268,6 +276,7 @@ Endpoints "available_from": null, "available_until": null, "hidden_if_available": null, + "hidden_if_item_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -402,6 +411,7 @@ Endpoints "available_from": null, "available_until": null, "hidden_if_available": null, + "hidden_if_item_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -517,6 +527,7 @@ Endpoints "available_from": null, "available_until": null, "hidden_if_available": null, + "hidden_if_item_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -619,6 +630,7 @@ Endpoints "available_from": null, "available_until": null, "hidden_if_available": null, + "hidden_if_item_available": null, "require_voucher": false, "hide_without_voucher": false, "allow_cancel": true, @@ -753,6 +765,7 @@ Endpoints "available_from": null, "available_until": null, "hidden_if_available": null, + "hidden_if_item_available": null, "require_voucher": false, "hide_without_voucher": false, "generate_tickets": null, diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 09e9777e01..313de305e6 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -239,7 +239,8 @@ class ItemSerializer(I18nAwareModelSerializer): 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets', - 'show_quota_left', 'hidden_if_available', 'allow_waitinglist', 'issue_giftcard', 'meta_data', + 'show_quota_left', 'hidden_if_available', 'hidden_if_item_available', 'allow_waitinglist', + 'issue_giftcard', 'meta_data', 'require_membership', 'require_membership_types', 'require_membership_hidden', 'grant_membership_type', 'grant_membership_duration_like_event', 'grant_membership_duration_days', 'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until', diff --git a/src/pretix/base/migrations/0249_hidden_if_item_available.py b/src/pretix/base/migrations/0249_hidden_if_item_available.py new file mode 100644 index 0000000000..648f351cba --- /dev/null +++ b/src/pretix/base/migrations/0249_hidden_if_item_available.py @@ -0,0 +1,22 @@ +# Generated by Django 4.2.4 on 2023-10-30 11:50 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("pretixbase", "0248_item_free_price_suggestion"), + ] + + operations = [ + migrations.AddField( + model_name="item", + name="hidden_if_item_available", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.SET_NULL, + to="pretixbase.item", + ), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index be2d67397a..cf02419658 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -857,6 +857,10 @@ class Event(EventMixin, LoggedModel): v.item = i v.save(force_insert=True) + 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() + for imv in ItemMetaValue.objects.filter(item__event=other): imv.pk = None imv.property = item_meta_properties_map[imv.property_id] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 44c2dbaff2..15750ff8c6 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -494,13 +494,24 @@ class Item(LoggedModel): 'Quota', null=True, blank=True, on_delete=models.SET_NULL, - verbose_name=_("Only show after sellout of"), + verbose_name=pgettext_lazy("hidden_if_available_legacy", "Only show after sellout of"), help_text=_("If you select a quota here, this product will only be shown when that quota is " "unavailable. If combined with the option to hide sold-out products, this allows you to " "swap out products for more expensive ones once they are sold out. There might be a short period " "in which both products are visible while all tickets in the referenced quota are reserved, " "but not yet sold.") ) + hidden_if_item_available = models.ForeignKey( + 'Item', + null=True, blank=True, + on_delete=models.SET_NULL, + verbose_name=_("Only show after sellout of"), + help_text=_("If you select a product here, this product will only be shown when that product is " + "sold out. If combined with the option to hide sold-out products, this allows you to " + "swap out products for more expensive ones once the cheaper option is sold out. There might " + "be a short period in which both products are visible while all tickets of the referenced " + "product are reserved, but not yet sold.") + ) require_voucher = models.BooleanField( verbose_name=_('This product can only be bought using a voucher.'), default=False, diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index aec549810e..81d77d76c0 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -45,7 +45,7 @@ from django.db.models import Max from django.forms.formsets import DELETION_FIELD_NAME from django.urls import reverse from django.utils.functional import cached_property -from django.utils.html import escape +from django.utils.html import escape, format_html from django.utils.safestring import mark_safe from django.utils.translation import ( gettext as __, gettext_lazy as _, pgettext_lazy, @@ -387,6 +387,7 @@ class ItemCreateForm(I18nModelForm): 'allow_waitinglist', 'show_quota_left', 'hidden_if_available', + 'hidden_if_item_available', 'require_bundling', 'require_membership', 'grant_membership_type', @@ -550,19 +551,43 @@ class ItemUpdateForm(I18nModelForm): widget=forms.CheckboxSelectMultiple ) change_decimal_field(self.fields['default_price'], self.event.currency) - self.fields['hidden_if_available'].queryset = self.event.quotas.all() - self.fields['hidden_if_available'].widget = Select2( + + if self.instance.hidden_if_available_id: + self.fields['hidden_if_available'].queryset = self.event.quotas.all() + self.fields['hidden_if_available'].help_text = format_html( + "{} {}", + _("This option is deprecated. For new products, use the newer option below that refers to another " + "product instead of a quota."), + self.fields['hidden_if_available'].help_text + ) + self.fields['hidden_if_available'].widget = Select2( + attrs={ + 'data-model-select2': 'generic', + 'data-select2-url': reverse('control:event.items.quotas.select2', kwargs={ + 'event': self.event.slug, + 'organizer': self.event.organizer.slug, + }), + 'data-placeholder': _('Shown independently of other products') + } + ) + self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices + self.fields['hidden_if_available'].required = False + else: + del self.fields['hidden_if_available'] + + self.fields['hidden_if_item_available'].queryset = self.event.items.exclude(id=self.instance.id) + self.fields['hidden_if_item_available'].widget = Select2( attrs={ 'data-model-select2': 'generic', - 'data-select2-url': reverse('control:event.items.quotas.select2', kwargs={ + 'data-select2-url': reverse('control:event.items.select2', kwargs={ 'event': self.event.slug, 'organizer': self.event.organizer.slug, }), 'data-placeholder': _('Shown independently of other products') } ) - self.fields['hidden_if_available'].widget.choices = self.fields['hidden_if_available'].choices - self.fields['hidden_if_available'].required = False + self.fields['hidden_if_item_available'].widget.choices = self.fields['hidden_if_item_available'].choices + self.fields['hidden_if_item_available'].required = False self.fields['category'].queryset = self.instance.event.categories.all() self.fields['category'].widget = Select2( @@ -683,6 +708,7 @@ class ItemUpdateForm(I18nModelForm): 'require_bundling', 'show_quota_left', 'hidden_if_available', + 'hidden_if_item_available', 'issue_giftcard', 'require_membership', 'require_membership_types', @@ -709,6 +735,7 @@ class ItemUpdateForm(I18nModelForm): 'validity_fixed_from': SplitDateTimeField, 'validity_fixed_until': SplitDateTimeField, 'hidden_if_available': SafeModelChoiceField, + 'hidden_if_item_available': SafeModelChoiceField, 'grant_membership_type': SafeModelChoiceField, 'require_membership_types': SafeModelMultipleChoiceField, } diff --git a/src/pretix/control/templates/pretixcontrol/item/base.html b/src/pretix/control/templates/pretixcontrol/item/base.html index 3fdd5603eb..6c0c4a59b6 100644 --- a/src/pretix/control/templates/pretixcontrol/item/base.html +++ b/src/pretix/control/templates/pretixcontrol/item/base.html @@ -32,6 +32,14 @@ {% endblocktrans %} {% endif %} + {% if not request.event.has_subevents and object.hidden_if_item_available and object.hidden_if_item_available.check_quotas.0 == 100 %} +
+ {% blocktrans trimmed %} + This product is currently not being shown since you configured below that it should only be visible + if a certain other product is already sold out. + {% endblocktrans %} +
+ {% endif %} {% block inside %} {% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index ae9e8f8766..300a39fe96 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -169,7 +169,10 @@ {% endif %} {% bootstrap_field form.allow_cancel layout="control" %} {% bootstrap_field form.allow_waitinglist layout="control" %} - {% bootstrap_field form.hidden_if_available layout="control" %} + {% if form.hidden_if_available %} + {% bootstrap_field form.hidden_if_available layout="control" %} + {% endif %} + {% bootstrap_field form.hidden_if_item_available layout="control" %} {% for v in formsets.values %}
diff --git a/src/pretix/locale/de/LC_MESSAGES/django.po b/src/pretix/locale/de/LC_MESSAGES/django.po index 5bb25ea875..87cab2fc6d 100644 --- a/src/pretix/locale/de/LC_MESSAGES/django.po +++ b/src/pretix/locale/de/LC_MESSAGES/django.po @@ -4255,7 +4255,7 @@ msgstr "Produktbild" #: pretix/base/models/items.py:491 msgid "Only show after sellout of" -msgstr "Nicht anzeigen, wenn anderes Kontingent verfügbar" +msgstr "Nicht anzeigen, wenn anderes Produkt verfügbar" #: pretix/base/models/items.py:492 msgid "" diff --git a/src/pretix/locale/de_Informal/LC_MESSAGES/django.po b/src/pretix/locale/de_Informal/LC_MESSAGES/django.po index 0b8ffc5747..e2cb392411 100644 --- a/src/pretix/locale/de_Informal/LC_MESSAGES/django.po +++ b/src/pretix/locale/de_Informal/LC_MESSAGES/django.po @@ -4252,7 +4252,7 @@ msgstr "Produktbild" #: pretix/base/models/items.py:491 msgid "Only show after sellout of" -msgstr "Nicht anzeigen, wenn anderes Kontingent verfügbar" +msgstr "Nicht anzeigen, wenn anderes Produkt verfügbar" #: pretix/base/models/items.py:492 msgid "" diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 2d7f8371cb..e2e5a8ab52 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -127,52 +127,77 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require else: prefetch_membership_types = [] + prefetch_var = Prefetch( + 'variations', + to_attr='available_variations', + queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate( + subevent_disabled=Exists( + SubEventItemVariation.objects.filter( + Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()), + variation_id=OuterRef('pk'), + subevent=subevent, + ) + ), + ).filter( + variation_q, + active=True, + sales_channels__contains=channel, + quotas__isnull=False, + subevent_disabled=False + ).prefetch_related( + *prefetch_membership_types, + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( + subevent=subevent)) + ).distinct() + ) + prefetch_quotas = Prefetch( + 'quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent) + ) + prefetch_bundles = Prefetch( + 'bundles', + queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related( + Prefetch('bundled_item', + queryset=event.items.using(settings.DATABASE_REPLICA).select_related( + 'tax_rule').prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( + subevent=subevent)), + )), + Prefetch('bundled_variation', + queryset=ItemVariation.objects.using( + settings.DATABASE_REPLICA + ).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related( + Prefetch('quotas', + to_attr='_subevent_quotas', + queryset=event.quotas.using(settings.DATABASE_REPLICA).filter( + subevent=subevent)), + )), + ) + ) + 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', ).prefetch_related( *prefetch_membership_types, - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)), - Prefetch('bundles', - queryset=ItemBundle.objects.using(settings.DATABASE_REPLICA).prefetch_related( - Prefetch('bundled_item', - queryset=event.items.using(settings.DATABASE_REPLICA).select_related('tax_rule').prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)), - )), - Prefetch('bundled_variation', - queryset=ItemVariation.objects.using( - settings.DATABASE_REPLICA - ).select_related('item', 'item__tax_rule').filter(item__event=event).prefetch_related( - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)), - )), - )), - Prefetch('variations', to_attr='available_variations', - queryset=ItemVariation.objects.using(settings.DATABASE_REPLICA).annotate( - subevent_disabled=Exists( - SubEventItemVariation.objects.filter( - Q(disabled=True) | Q(available_from__gt=now()) | Q(available_until__lt=now()), - variation_id=OuterRef('pk'), - subevent=subevent, - ) - ), - ).filter( - variation_q, - active=True, - sales_channels__contains=channel, - quotas__isnull=False, - subevent_disabled=False - ).prefetch_related( - *prefetch_membership_types, - Prefetch('quotas', - to_attr='_subevent_quotas', - queryset=event.quotas.using(settings.DATABASE_REPLICA).filter(subevent=subevent)) - ).distinct()), + Prefetch( + 'hidden_if_item_available', + queryset=event.items.annotate( + has_variations=Count('variations'), + ).prefetch_related( + prefetch_var, + prefetch_quotas, + prefetch_bundles, + ) + ), + prefetch_quotas, + prefetch_var, + prefetch_bundles, ).annotate( quotac=Count('quotas'), has_variations=Count('variations'), @@ -256,6 +281,19 @@ def get_grouped_items(event, subevent=None, voucher=None, channel='web', require item._remove = True continue + if item.hidden_if_item_available: + if item.hidden_if_item_available.has_variations: + dependency_available = any( + var.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True)[0] == Quota.AVAILABILITY_OK + for var in item.hidden_if_item_available.available_variations + ) + else: + q = item.hidden_if_item_available.check_quotas(subevent=subevent, _cache=quota_cache, include_bundled=True) + dependency_available = q[0] == Quota.AVAILABILITY_OK + if dependency_available: + item._remove = True + continue + if item.require_membership and item.require_membership_hidden: if not memberships or not any([m.membership_type in item.require_membership_types.all() for m in memberships]): item._remove = True diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index ef3bb62cf1..29c4612c31 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -277,6 +277,7 @@ TEST_ITEM_RES = { "min_per_order": None, "max_per_order": None, "hidden_if_available": None, + "hidden_if_item_available": None, "checkin_attention": False, "has_variations": False, "require_approval": False, diff --git a/src/tests/base/test_event_clone.py b/src/tests/base/test_event_clone.py index 8fbd9de6ee..e09a02c9d1 100644 --- a/src/tests/base/test_event_clone.py +++ b/src/tests/base/test_event_clone.py @@ -71,7 +71,8 @@ def test_full_clone_same_organizer(): # todo: test that item pictures are copied, not linked ItemMetaValue.objects.create(item=item1, property=item_meta, value="Foo") assert item1.meta_data - item2 = event.items.create(category=category, tax_rule=tax_rule, name="T-shirt", default_price=15) + item2 = event.items.create(category=category, tax_rule=tax_rule, name="T-shirt", default_price=15, + hidden_if_item_available=item1) item2v = item2.variations.create(value="red", default_price=15) item2v.meta_values.create(property=item_meta, value="Bar") item2.require_membership_types.add(membership_type) @@ -156,6 +157,7 @@ def test_full_clone_same_organizer(): assert copied_item1.addons.get().addon_category == copied_event.categories.get() assert copied_item1.bundles.get().bundled_item == copied_item2 assert copied_item1.bundles.get().bundled_variation == copied_item2.variations.get() + assert copied_item2.hidden_if_item_available == copied_item1 assert copied_q1.items.get() == copied_item1 assert copied_q2.items.get() == copied_item2 assert copied_q2.variations.get() == copied_item2.variations.get() diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 14102710fa..19fcd96d39 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -3336,6 +3336,33 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): assert 'Workshop 1' in response.content.decode() assert 'Workshop 2' in response.content.decode() + def test_set_addons_hidden_if_item_available(self): + with scopes_disabled(): + self.workshopquota2 = Quota.objects.create(event=self.event, name='Workshop 1', size=5) + self.workshopquota2.items.add(self.workshop2) + self.workshopquota2.variations.add(self.workshop2a) + self.workshop2.hidden_if_item_available = self.workshop1 + self.workshop2.save() + + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() - timedelta(minutes=10) + ) + + response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True) + self.assertRedirects(response, '/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), + target_status_code=200) + assert 'Workshop 1' in response.content.decode() + assert 'Workshop 2' not in response.content.decode() + + self.workshopquota.size = 0 + self.workshopquota.save() + + response = self.client.get('/%s/%s/checkout/addons/' % (self.orga.slug, self.event.slug), follow=True) + assert 'Workshop 1' in response.content.decode() + assert 'Workshop 2' in response.content.decode() + def test_set_addons_subevent(self): with scopes_disabled(): self.event.has_subevents = True diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 54779ff994..4ed921aa26 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -631,6 +631,30 @@ class ItemDisplayTest(EventTestMixin, SoupTest): self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text) self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text) + def test_hidden_if_item_available(self): + with scopes_disabled(): + q = Quota.objects.create(event=self.event, name='Early-bird', size=10) + q2 = Quota.objects.create(event=self.event, name='Late-bird', size=10) + item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=12) + item2 = Item.objects.create(event=self.event, name='Late-bird ticket', default_price=12, + hidden_if_item_available=item) + q.items.add(item) + q2.items.add(item2) + self.event.settings.hide_sold_out = True + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text) + self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text) + self.assertNotIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text) + + q.size = 0 + q.save() + + doc = self.get_doc('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertNotIn("Early-bird", doc.select("section:nth-of-type(1)")[0].text) + self.assertNotIn("SOLD OUT", doc.select("section:nth-of-type(1)")[0].text) + self.assertIn("Late-bird", doc.select("section:nth-of-type(1)")[0].text) + def test_bundle_sold_out(self): with scopes_disabled(): q = Quota.objects.create(event=self.event, name='Quota', size=2)