Add approval requirement option to product variations (#2381)

This commit is contained in:
ser8phin
2022-01-05 18:04:12 +01:00
committed by GitHub
parent 223b160c0c
commit 7a4db8ea23
11 changed files with 103 additions and 9 deletions

View File

@@ -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": [],

View File

@@ -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):

View File

@@ -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),
),
]

View File

@@ -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.')

View File

@@ -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):
"""

View File

@@ -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

View File

@@ -713,6 +713,7 @@ class ItemVariationForm(I18nModelForm):
'default_price',
'original_price',
'description',
'require_approval',
'require_membership',
'require_membership_hidden',
'require_membership_types',

View File

@@ -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" %}
<div data-display-dependency="#{{ form.require_membership.id_for_label }}">
@@ -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" %}
<div data-display-dependency="#{{ formset.empty_form.require_membership.id_for_label }}">

View File

@@ -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

View File

@@ -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": [],

View File

@@ -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):