From 7a4db8ea2326d240d5c83942c23955c170f4ba6f Mon Sep 17 00:00:00 2001 From: ser8phin Date: Wed, 5 Jan 2022 18:04:12 +0100 Subject: [PATCH] Add approval requirement option to product variations (#2381) --- doc/api/resources/item_variations.rst | 9 +++++ src/pretix/api/serializers/item.py | 10 +++-- .../0205_itemvariation_require_approval.py | 18 +++++++++ src/pretix/base/models/items.py | 12 +++++- src/pretix/base/models/orders.py | 7 ++++ src/pretix/base/services/orders.py | 4 +- src/pretix/control/forms/item.py | 1 + .../item/include_variations.html | 2 + src/pretix/presale/checkoutflow.py | 4 +- src/tests/api/test_items.py | 5 +++ src/tests/presale/test_checkout.py | 40 +++++++++++++++++++ 11 files changed, 103 insertions(+), 9 deletions(-) create mode 100644 src/pretix/base/migrations/0205_itemvariation_require_approval.py diff --git a/doc/api/resources/item_variations.rst b/doc/api/resources/item_variations.rst index 4419bb7b2f..c5ce44411b 100644 --- a/doc/api/resources/item_variations.rst +++ b/doc/api/resources/item_variations.rst @@ -24,6 +24,9 @@ active boolean If ``false``, t description multi-lingual string A public description of the variation. May contain Markdown syntax or can be ``null``. position integer An integer, used for sorting +require_approval boolean If ``true``, orders with this variation will need to be + approved by the event organizer before they can be + paid. require_membership boolean If ``true``, booking this variation requires an active membership. require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will be hidden from users without a valid membership. @@ -76,6 +79,7 @@ Endpoints "en": "S" }, "active": true, + "require_approval": false, "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], @@ -97,6 +101,7 @@ Endpoints "en": "L" }, "active": true, + "require_approval": false, "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], @@ -147,6 +152,7 @@ Endpoints "price": "10.00", "original_price": null, "active": true, + "require_approval": false, "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], @@ -183,6 +189,7 @@ Endpoints "value": {"en": "Student"}, "default_price": "10.00", "active": true, + "require_approval": false, "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], @@ -209,6 +216,7 @@ Endpoints "price": "10.00", "original_price": null, "active": true, + "require_approval": false, "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], @@ -266,6 +274,7 @@ Endpoints "price": "10.00", "original_price": null, "active": false, + "require_approval": false, "require_membership": false, "require_membership_hidden": false, "require_membership_types": [], diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 8eb3e05149..5985841a20 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -58,8 +58,9 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer): class Meta: model = ItemVariation fields = ('id', 'value', 'active', 'description', - 'position', 'default_price', 'price', 'original_price', - 'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until', + 'position', 'default_price', 'price', 'original_price', 'require_approval', + 'require_membership', 'require_membership_types', + 'require_membership_hidden', 'available_from', 'available_until', 'sales_channels', 'hide_without_voucher',) def __init__(self, *args, **kwargs): @@ -74,8 +75,9 @@ class ItemVariationSerializer(I18nAwareModelSerializer): class Meta: model = ItemVariation fields = ('id', 'value', 'active', 'description', - 'position', 'default_price', 'price', 'original_price', - 'require_membership', 'require_membership_types', 'require_membership_hidden', 'available_from', 'available_until', + 'position', 'default_price', 'price', 'original_price', 'require_approval', + 'require_membership', 'require_membership_types', + 'require_membership_hidden', 'available_from', 'available_until', 'sales_channels', 'hide_without_voucher',) def __init__(self, *args, **kwargs): diff --git a/src/pretix/base/migrations/0205_itemvariation_require_approval.py b/src/pretix/base/migrations/0205_itemvariation_require_approval.py new file mode 100644 index 0000000000..300a9e4e16 --- /dev/null +++ b/src/pretix/base/migrations/0205_itemvariation_require_approval.py @@ -0,0 +1,18 @@ +# Generated by Django 3.2.9 on 2021-12-13 14:21 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0204_orderposition_backfill_is_bundled'), + ] + + operations = [ + migrations.AddField( + model_name='itemvariation', + name='require_approval', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index b9f34ce602..1dc63fe9da 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -764,6 +764,9 @@ class ItemVariation(models.Model): :type default_price: decimal.Decimal :param original_price: The item's "original" price. Will not be used for any calculations, will just be shown. :type original_price: decimal.Decimal + :param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after + approval by an administrator + :type require_approval: bool """ item = models.ForeignKey( Item, @@ -799,6 +802,13 @@ class ItemVariation(models.Model): 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.') ) + require_approval = models.BooleanField( + verbose_name=_('Require approval'), + default=False, + help_text=_('If this variation is part of an order, the order will be put into an "approval" state and ' + 'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for ' + 'discounted tickets that are only available to specific groups.'), + ) require_membership = models.BooleanField( verbose_name=_('Require a valid membership'), default=False, @@ -832,7 +842,7 @@ class ItemVariation(models.Model): blank=True, ) hide_without_voucher = models.BooleanField( - verbose_name=_('This variation will only be shown if a voucher matching the product is redeemed.'), + verbose_name=_('Show only if a matching voucher 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.') diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index d56f2e92c5..93cef60999 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1442,6 +1442,13 @@ class AbstractPosition(models.Model): lines = [r.strip() for r in lines if r] return '\n'.join(lines).strip() + def requires_approval(self): + if self.item.require_approval: + return True + if self.variation and self.variation.require_approval: + return True + return False + class OrderPayment(models.Model): """ diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index e156dd0d88..dbd90925b8 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -856,7 +856,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d total=total, testmode=True if sales_channel.testmode_supported and event.testmode else False, meta_info=json.dumps(meta_info or {}), - require_approval=any(p.item.require_approval for p in positions), + require_approval=any(p.requires_approval() for p in positions), sales_channel=sales_channel.identifier, customer=customer, ) @@ -2071,7 +2071,7 @@ class OrderChangeManager: split_order.code = None split_order.datetime = now() split_order.secret = generate_secret() - split_order.require_approval = self.order.require_approval and any(p.item.require_approval for p in split_positions) + split_order.require_approval = self.order.require_approval and any(p.requires_approval() for p in split_positions) split_order.save() split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={ 'original_order': self.order.code diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index bb5d9797d3..d08b516922 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -713,6 +713,7 @@ class ItemVariationForm(I18nModelForm): 'default_price', 'original_price', 'description', + 'require_approval', 'require_membership', 'require_membership_hidden', 'require_membership_types', diff --git a/src/pretix/control/templates/pretixcontrol/item/include_variations.html b/src/pretix/control/templates/pretixcontrol/item/include_variations.html index 6a6de82c55..5632ac0194 100644 --- a/src/pretix/control/templates/pretixcontrol/item/include_variations.html +++ b/src/pretix/control/templates/pretixcontrol/item/include_variations.html @@ -73,6 +73,7 @@ {% bootstrap_field form.available_until layout="control" %} {% bootstrap_field form.sales_channels layout="control" %} {% bootstrap_field form.hide_without_voucher layout="control" %} + {% bootstrap_field form.require_approval layout="control" %} {% if form.require_membership %} {% bootstrap_field form.require_membership layout="control" %}
@@ -144,6 +145,7 @@ {% bootstrap_field formset.empty_form.available_until layout="control" %} {% bootstrap_field formset.empty_form.sales_channels layout="control" %} {% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %} + {% bootstrap_field formset.empty_form.require_approval layout="control" %} {% if formset.empty_form.require_membership %} {% bootstrap_field formset.empty_form.require_membership layout="control" %}
diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index ff2b2b8452..f19cb26615 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1161,7 +1161,7 @@ class PaymentStep(CartMixin, TemplateFlowStep): self.request = request for cartpos in get_cart(self.request): - if cartpos.item.require_approval: + if cartpos.requires_approval(): if 'payment' in self.cart_session: del self.cart_session['payment'] return False @@ -1206,7 +1206,7 @@ class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): if self.payment_provider: ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request) ctx['payment_provider'] = self.payment_provider - ctx['require_approval'] = any(cp.item.require_approval for cp in ctx['cart']['positions']) + ctx['require_approval'] = any(cp.requires_approval() for cp in ctx['cart']['positions']) ctx['addr'] = self.invoice_address ctx['confirm_messages'] = self.confirm_messages ctx['cart_session'] = self.cart_session diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 95e269710f..5a3317a10c 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -376,6 +376,7 @@ def test_item_detail_variations(token_client, organizer, event, team, item): "active": True, "description": None, "position": 0, + "require_approval": False, "require_membership": False, "require_membership_hidden": False, "require_membership_types": [], @@ -508,6 +509,7 @@ def test_item_create_with_variation(token_client, organizer, event, item, catego "en": "Comment" }, "active": True, + "require_approval": True, "require_membership": False, "require_membership_hidden": False, "require_membership_types": [], @@ -525,6 +527,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 new_item.variations.first().require_approval is True assert set(new_item.variations.first().sales_channels) == set(get_all_sales_channels().keys()) @@ -1212,6 +1215,7 @@ TEST_VARIATIONS_RES = { "position": 0, "default_price": None, "price": "23.00", + "require_approval": False, "require_membership": False, "require_membership_hidden": False, "require_membership_types": [], @@ -1230,6 +1234,7 @@ TEST_VARIATIONS_UPDATE = { "description": None, "position": 1, "default_price": "20.0", + "require_approval": False, "require_membership": False, "require_membership_hidden": False, "require_membership_types": [], diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 310f0fbc47..6513122555 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -2797,6 +2797,46 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase): with scopes_disabled(): self.assertEqual(Order.objects.first().locale, 'de') + def test_variation_require_approval(self): + self.workshop2a.require_approval = True + self.workshop2a.save() + with scopes_disabled(): + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.workshop2, variation=self.workshop2a, + price=0, expires=now() + timedelta(minutes=10) + ) + + 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.assertFalse(CartPosition.objects.filter(id=cr1.id).exists()) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(Order.objects.first().status, Order.STATUS_PENDING) + self.assertTrue(Order.objects.first().require_approval) + self.assertEqual(OrderPosition.objects.count(), 1) + self.assertEqual(Invoice.objects.count(), 0) + + def test_item_with_variations_require_approval(self): + self.workshop2.require_approval = True + self.workshop2.save() + with scopes_disabled(): + cr1 = CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.workshop2, variation=self.workshop2a, + price=0, expires=now() + timedelta(minutes=10) + ) + + 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.assertFalse(CartPosition.objects.filter(id=cr1.id).exists()) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(Order.objects.first().status, Order.STATUS_PENDING) + self.assertTrue(Order.objects.first().require_approval) + self.assertEqual(OrderPosition.objects.count(), 1) + self.assertEqual(Invoice.objects.count(), 0) + class QuestionsTestCase(BaseCheckoutTestCase, TestCase):