diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index 6a5a2f4614..35dc782417 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -47,6 +47,8 @@ tag string A string that i comment string An internal comment on the voucher subevent integer ID of the date inside an event series this voucher belongs to (or ``null``). show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``. +all_addons_included boolean If set to ``true``, all add-on products for the product purchased with this voucher are included in the base price. +all_bundles_included boolean If set to ``true``, all bundled products for the product purchased with this voucher are added without their designated price. ===================================== ========================== ======================================================= @@ -95,6 +97,9 @@ Endpoints "comment": "", "seat": null, "subevent": null, + "show_hidden_items": false, + "all_addons_included": false, + "all_bundles_included": false } ] } @@ -161,7 +166,10 @@ Endpoints "tag": "testvoucher", "comment": "", "seat": null, - "subevent": null + "subevent": null, + "show_hidden_items": false, + "all_addons_included": false, + "all_bundles_included": false } :param organizer: The ``slug`` field of the organizer to fetch @@ -198,7 +206,10 @@ Endpoints "quota": null, "tag": "testvoucher", "comment": "", - "subevent": null + "subevent": null, + "show_hidden_items": false, + "all_addons_included": false, + "all_bundles_included": false } **Example response**: @@ -225,7 +236,10 @@ Endpoints "tag": "testvoucher", "comment": "", "seat": null, - "subevent": null + "subevent": null, + "show_hidden_items": false, + "all_addons_included": false, + "all_bundles_included": false } :param organizer: The ``slug`` field of the organizer to create a voucher for @@ -264,7 +278,10 @@ Endpoints "quota": null, "tag": "testvoucher", "comment": "", - "subevent": null + "subevent": null, + "show_hidden_items": false, + "all_addons_included": false, + "all_bundles_included": false }, { "code": "ASDKLJCYXCASDASD", @@ -279,7 +296,10 @@ Endpoints "quota": null, "tag": "testvoucher", "comment": "", - "subevent": null + "subevent": null, + "show_hidden_items": false, + "all_addons_included": false, + "all_bundles_included": false }, **Example response**: @@ -353,7 +373,10 @@ Endpoints "tag": "testvoucher", "comment": "", "seat": null, - "subevent": null + "subevent": null, + "show_hidden_items": false, + "all_addons_included": false, + "all_bundles_included": false } :param organizer: The ``slug`` field of the organizer to modify diff --git a/src/pretix/api/serializers/voucher.py b/src/pretix/api/serializers/voucher.py index 74a90ada7a..c081f99aa8 100644 --- a/src/pretix/api/serializers/voucher.py +++ b/src/pretix/api/serializers/voucher.py @@ -63,7 +63,8 @@ class VoucherSerializer(I18nAwareModelSerializer): model = Voucher fields = ('id', 'code', 'max_usages', 'redeemed', 'min_usages', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', - 'tag', 'comment', 'subevent', 'show_hidden_items', 'seat') + 'tag', 'comment', 'subevent', 'show_hidden_items', 'seat', 'all_addons_included', + 'all_bundles_included') read_only_fields = ('id', 'redeemed') list_serializer_class = VoucherListSerializer diff --git a/src/pretix/base/migrations/0240_auto_20230516_1119.py b/src/pretix/base/migrations/0240_auto_20230516_1119.py new file mode 100644 index 0000000000..c36d872482 --- /dev/null +++ b/src/pretix/base/migrations/0240_auto_20230516_1119.py @@ -0,0 +1,23 @@ +# Generated by Django 3.2.18 on 2023-05-16 11:19 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0239_giftcard_info'), + ] + + operations = [ + migrations.AddField( + model_name='voucher', + name='all_addons_included', + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name='voucher', + name='all_bundles_included', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 036688abd4..ef85e39e62 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -2832,8 +2832,12 @@ class CartPosition(AbstractPosition): if self.is_bundled: bundle = self.addon_to.item.bundles.filter(bundled_item=self.item, bundled_variation=self.variation).first() if bundle: - listed_price = bundle.designated_price - price_after_voucher = bundle.designated_price + if self.addon_to.voucher_id and self.addon_to.voucher.all_bundles_included: + listed_price = Decimal('0.00') + price_after_voucher = Decimal('0.00') + else: + listed_price = bundle.designated_price + price_after_voucher = bundle.designated_price if listed_price != self.listed_price or price_after_voucher != self.price_after_voucher: self.listed_price = listed_price diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index c6f3ea337b..bc7c3c2f0f 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -296,6 +296,14 @@ class Voucher(LoggedModel): verbose_name=_("Shows hidden products that match this voucher"), default=True ) + all_addons_included = models.BooleanField( + verbose_name=_("Offer all add-on products for free when redeeming this voucher"), + default=False + ) + all_bundles_included = models.BooleanField( + verbose_name=_("Include all bundled products without a designated price when redeeming this voucher"), + default=False + ) objects = ScopedManager(organizer='event__organizer') diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index a6e5fcc18a..0355668616 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -512,7 +512,10 @@ class CartManager: if cp.is_bundled: bundle = cp.addon_to.item.bundles.filter(bundled_item=cp.item, bundled_variation=cp.variation).first() if bundle: - listed_price = bundle.designated_price or Decimal('0.00') + if cp.addon_to.voucher_id and cp.addon_to.voucher.all_bundles_included: + listed_price = Decimal('0.00') + else: + listed_price = bundle.designated_price else: listed_price = cp.price price_after_voucher = listed_price @@ -712,6 +715,11 @@ class CartManager: else: bundle_quotas = [] + if voucher and voucher.all_bundles_included: + bundled_price = Decimal('0.00') + else: + bundled_price = bundle.designated_price + bop = self.AddOperation( count=bundle.count, item=bitem, @@ -722,8 +730,8 @@ class CartManager: subevent=subevent, bundled=[], seat=None, - listed_price=bundle.designated_price, - price_after_voucher=bundle.designated_price, + listed_price=bundled_price, + price_after_voucher=bundled_price, custom_price_input=None, custom_price_input_is_net=False, voucher_ignored=False, @@ -809,7 +817,6 @@ class CartManager: quota_diff = Counter() # Quota -> Number of usages operations = [] available_categories = defaultdict(set) # CartPos -> Category IDs to choose from - price_included = defaultdict(dict) # CartPos -> CategoryID -> bool(price is included) toplevel_cp = self.positions.filter( addon_to__isnull=True ).prefetch_related( @@ -819,7 +826,6 @@ class CartManager: # Prefill some of the cache containers for cp in toplevel_cp: available_categories[cp.pk] = {iao.addon_category_id for iao in cp.item.addons.all()} - price_included[cp.pk] = {iao.addon_category_id: iao.price_included for iao in cp.item.addons.all()} cpcache[cp.pk] = cp for a in cp.addons.all(): if not a.is_bundled: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 4a2e12a32b..19e8719397 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1892,7 +1892,7 @@ class OrderChangeManager: input_addons[op.id][a['item'], a['variation']] = a.get('count', 1) selected_addons[op.id, item.category_id][a['item'], a['variation']] = a.get('count', 1) - if price_included[op.pk].get(item.category_id): + if price_included[op.pk].get(item.category_id) or (op.voucher_id and op.voucher.all_addons_included): price = TAXED_ZERO else: price = get_price( diff --git a/src/pretix/base/services/pricing.py b/src/pretix/base/services/pricing.py index d335827135..d7ed1e7f4e 100644 --- a/src/pretix/base/services/pricing.py +++ b/src/pretix/base/services/pricing.py @@ -110,6 +110,8 @@ def is_included_for_free(item: Item, addon_to: AbstractPosition): return True except ItemAddOn.DoesNotExist: pass + if addon_to.voucher_id and addon_to.voucher.all_addons_included: + return True return False diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 5b8109cf4e..e82de833ee 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -72,7 +72,8 @@ class VoucherForm(I18nModelForm): localized_fields = '__all__' fields = [ 'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', - 'comment', 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget' + 'comment', 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included', + 'all_bundles_included', 'budget' ] field_classes = { 'valid_until': SplitDateTimeField, @@ -308,7 +309,8 @@ class VoucherBulkForm(VoucherForm): localized_fields = '__all__' fields = [ 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment', - 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget' + 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'all_addons_included', + 'all_bundles_included', 'budget' ] field_classes = { 'valid_until': SplitDateTimeField, diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html index afc33900c3..03af1073ec 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html @@ -78,6 +78,8 @@ {% bootstrap_field form.tag layout="control" %} {% bootstrap_field form.comment layout="control" %} {% bootstrap_field form.show_hidden_items layout="control" %} + {% bootstrap_field form.all_addons_included layout="control" %} + {% bootstrap_field form.all_bundles_included layout="control" %}
{% trans "Send out emails" %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html index 92fc3206f0..bcb5a82811 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html @@ -90,6 +90,8 @@ {% bootstrap_field form.tag layout="control" %} {% bootstrap_field form.comment layout="control" %} {% bootstrap_field form.show_hidden_items layout="control" %} + {% bootstrap_field form.all_addons_included layout="control" %} + {% bootstrap_field form.all_bundles_included layout="control" %}
{% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %} diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 6202f0fc95..5eb4befa85 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -585,7 +585,7 @@ class AddOnsStep(CartMixin, AsyncAction, TemplateFlowStep): if items: formsetentry['categories'].append({ 'category': iao.addon_category, - 'price_included': iao.price_included, + 'price_included': iao.price_included or (cartpos.voucher_id and cartpos.voucher.all_addons_included), 'multi_allowed': iao.multi_allowed, 'min_count': iao.min_count, 'max_count': iao.max_count, diff --git a/src/pretix/presale/templates/pretixpresale/event/voucher.html b/src/pretix/presale/templates/pretixpresale/event/voucher.html index 5e39f0a60d..0d13a64488 100644 --- a/src/pretix/presale/templates/pretixpresale/event/voucher.html +++ b/src/pretix/presale/templates/pretixpresale/event/voucher.html @@ -149,7 +149,7 @@ from {{ minprice }} {% endblocktrans %} {% elif not item.min_price and not item.max_price %} - {% if not item.mandatory_priced_addons %} + {% if not item.mandatory_priced_addons or voucher.all_addons_included %} {% trans "free" context "price" %} {% endif %} {% else %} @@ -207,7 +207,7 @@

{% elif not var.display_price.gross %} - {% if not item.mandatory_priced_addons or var.original_price %} + {% if not item.mandatory_priced_addons or var.original_price or voucher.all_addons_included %} {% trans "free" context "price" %} {% endif %} {% elif event.settings.display_net_prices %} @@ -349,7 +349,7 @@

{% elif not item.display_price.gross %} - {% if not item.mandatory_priced_addons or item.original_price %} + {% if not item.mandatory_priced_addons or item.original_price or voucher.all_addons_included %} {% trans "free" context "price" %} {% endif %} {% elif event.settings.display_net_prices %} diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index c1be32c3d7..138ad84a77 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -1361,7 +1361,7 @@ class OrderChangeMixin: if items: p.addon_form['categories'].append({ 'category': iao.addon_category, - 'price_included': iao.price_included, + 'price_included': iao.price_included or (p.voucher_id and p.voucher.all_addons_included), 'multi_allowed': iao.multi_allowed, 'min_count': iao.min_count, 'max_count': iao.max_count, diff --git a/src/tests/api/test_vouchers.py b/src/tests/api/test_vouchers.py index ba1d180bf3..aaf1253d27 100644 --- a/src/tests/api/test_vouchers.py +++ b/src/tests/api/test_vouchers.py @@ -79,6 +79,8 @@ TEST_VOUCHER_RES = { 'tag': 'Foo', 'comment': '', 'show_hidden_items': True, + 'all_addons_included': False, + 'all_bundles_included': False, 'subevent': None, 'seat': None, } diff --git a/src/tests/presale/test_bundle_prices.py b/src/tests/presale/test_bundle_prices.py index 09312fa0a7..0145492352 100644 --- a/src/tests/presale/test_bundle_prices.py +++ b/src/tests/presale/test_bundle_prices.py @@ -28,7 +28,7 @@ from django.utils.timezone import now from django_scopes import scopes_disabled from pretix.base.models import ( - CartPosition, Event, Item, OrderPosition, Organizer, Quota, + CartPosition, Event, Item, OrderPosition, Organizer, Quota, Voucher, ) from pretix.base.services.orders import _perform_order from pretix.testutils.sessions import get_cart_session_key @@ -116,6 +116,47 @@ class BundlePricesTest(TestCase): assert op2.item == self.food assert op2.tax_rate == Decimal('7.00') + def test_voucher_includes_bundles(self): + with scopes_disabled(): + v = Voucher.objects.create(item=self.ticket, value=Decimal('0.00'), event=self.event, price_mode='set', + all_bundles_included=True) + + # Verify correct price displayed on event page + response = self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug)) + self.assertContains(response, '23.00') + + # Verify correct price being added to cart + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d' % self.ticket.id: '1', + '_voucher_code': v.code + }, follow=True) + with scopes_disabled(): + cp1 = CartPosition.objects.get(is_bundled=False) + cp2 = CartPosition.objects.get(is_bundled=True) + + assert cp1.price == Decimal('0.00') + assert cp1.item == self.ticket + assert cp2.price == Decimal('0.00') + assert cp2.item == self.food + + # Make sure cart expires + cp1.expires = now() - datetime.timedelta(minutes=120) + cp1.save() + cp2.expires = now() - datetime.timedelta(minutes=120) + cp2.save() + + # Verify price is kept if cart expires and order is sent + with scopes_disabled(): + _perform_order(self.event, self._manual_payment(), [cp1.pk, cp2.pk], 'admin@example.org', 'en', None, {}, 'web') + op1 = OrderPosition.objects.get(is_bundled=False) + op2 = OrderPosition.objects.get(is_bundled=True) + assert op1.price == Decimal('0.00') + assert op1.item == self.ticket + assert op1.tax_rate == Decimal('19.00') + assert op2.price == Decimal('0.00') + assert op2.item == self.food + assert op2.tax_rate == Decimal('7.00') + def test_net_price_definitions(self): self.tr19.price_includes_tax = False self.tr19.save() diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index dab0d8d420..281db6dbe7 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -3119,6 +3119,27 @@ class CartBundleTest(CartTestMixin, TestCase): assert a.item == self.trans assert a.price == 1.5 + @classscope(attr='orga') + def test_simple_bundled_voucher_all_free(self): + v = Voucher.objects.create(item=self.ticket, value=Decimal('0.00'), event=self.event, price_mode='set', + all_bundles_included=True) + self.cm.add_new_items([ + { + 'item': self.ticket.pk, + 'variation': None, + 'voucher': v.code, + 'count': 1 + } + ]) + self.cm.commit() + cp = CartPosition.objects.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 0 + assert cp.addons.count() == 1 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 0 + @classscope(attr='orga') def test_voucher_on_base_product(self): v = self.event.vouchers.create(code="foo", item=self.ticket) diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index a1a9d19e96..cd9100cddd 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -2263,6 +2263,29 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): with scopes_disabled(): self.assertEqual(OrderPosition.objects.filter(item=self.workshop1).last().price, 0) + def test_addon_price_included_in_voucher(self): + with scopes_disabled(): + v = Voucher.objects.create(item=self.ticket, value=Decimal('0.00'), event=self.event, price_mode='set', + valid_until=now() + timedelta(days=2), all_addons_included=True) + ItemAddOn.objects.create(base_item=self.ticket, addon_category=self.workshopcat, min_count=1, + price_included=False) + cp1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=0, expires=now() - timedelta(minutes=10), voucher=v + ) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.workshop1, + price=0, expires=now() - timedelta(minutes=10), + addon_to=cp1 + ) + + self._set_payment() + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.content.decode(), "lxml") + self.assertEqual(len(doc.select(".thank-you")), 1) + with scopes_disabled(): + self.assertEqual(OrderPosition.objects.filter(item=self.workshop1).last().price, 0) + def test_confirm_price_changed_reverse_charge(self): self._enable_reverse_charge() self.ticket.default_price = 24 @@ -3970,6 +3993,27 @@ class CheckoutBundleTest(BaseCheckoutTestCase, TestCase): assert a.item == self.trans assert a.price == 1.5 + @classscope(attr='orga') + def test_expired_bundle_with_voucher_bundles_included(self): + v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, price_mode='none', + valid_until=now() + timedelta(days=2), all_bundles_included=True) + self.cp1.voucher = v + self.cp1.price = 23 + self.cp1.expires = now() - timedelta(minutes=10) + self.cp1.save() + self.bundled1.price = 0 + self.bundled1.expires = now() - timedelta(minutes=10) + self.bundled1.save() + oid = _perform_order(self.event, self._manual_payment(), [self.cp1.pk, self.bundled1.pk], 'admin@example.org', 'en', None, {}, 'web') + o = Order.objects.get(pk=oid['order_id']) + cp = o.positions.get(addon_to__isnull=True) + assert cp.item == self.ticket + assert cp.price == 23 + assert cp.addons.count() == 1 + a = cp.addons.get() + assert a.item == self.trans + assert a.price == 0 + @classscope(attr='orga') def test_expired_keep_price(self): self.cp1.expires = now() - timedelta(minutes=10) diff --git a/src/tests/presale/test_order_change.py b/src/tests/presale/test_order_change.py index 530050842d..4ac3876c18 100644 --- a/src/tests/presale/test_order_change.py +++ b/src/tests/presale/test_order_change.py @@ -650,6 +650,43 @@ class OrderChangeAddonsTest(BaseOrdersTest): self.order.refresh_from_db() assert self.order.total == Decimal('35.00') + def test_add_addon_included_in_voucher(self): + response = self.client.get( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret) + ) + assert response.status_code == 200 + assert 'Workshop 1' in response.content.decode() + + with scopes_disabled(): + v = self.event.vouchers.create(item=self.ticket, all_addons_included=True) + self.ticket_pos.voucher = v + self.ticket_pos.save() + + doc = BeautifulSoup(response.content.decode(), "lxml") + assert doc.select(f'input[name=cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}]') + + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), + { + f'cp_{self.ticket_pos.pk}_item_{self.workshop1.pk}': '1' + }, + follow=True + ) + doc = BeautifulSoup(response.content.decode(), "lxml") + form_data = extract_form_fields(doc.select('.main-box form')[0]) + form_data['confirm'] = 'true' + response = self.client.post( + '/%s/%s/order/%s/%s/change' % (self.orga.slug, self.event.slug, self.order.code, self.order.secret), form_data, follow=True + ) + assert 'alert-success' in response.content.decode() + + with scopes_disabled(): + new_pos = self.ticket_pos.addons.get() + assert new_pos.item == self.workshop1 + assert new_pos.price == Decimal('0.00') + self.order.refresh_from_db() + assert self.order.total == Decimal('23.00') + def test_add_addon_free_price(self): self.workshop1.free_price = True self.workshop1.save()