Voucher: Add min_usages parameter (#2853)

This commit is contained in:
Raphael Michel
2022-10-20 18:07:24 +02:00
committed by GitHub
parent ba2d908a89
commit d69d70cfb1
13 changed files with 233 additions and 8 deletions

View File

@@ -19,6 +19,8 @@ max_usages integer The maximum num
redeemed (default: 1).
redeemed integer The number of times this voucher already has been
redeemed.
min_usages integer The minimum number of times this voucher must be
redeemed on first usage (default: 1).
valid_until datetime The voucher expiration date (or ``null``).
block_quota boolean If ``true``, quota is blocked for this voucher.
allow_ignore_quota boolean If ``true``, this voucher can be redeemed even if a

View File

@@ -61,7 +61,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
class Meta:
model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
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')
read_only_fields = ('id', 'redeemed')

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.12 on 2022-10-12 09:13
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0222_alter_question_unique_together'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='min_usages',
field=models.PositiveIntegerField(default=1),
),
]

View File

@@ -137,6 +137,8 @@ class Voucher(LoggedModel):
:type max_usages: int
:param redeemed: The number of times this voucher already has been redeemed
:type redeemed: int
:param min_usages: The minimum number of times this voucher must be redeemed
:type min_usages: int
:param valid_until: The expiration date of this voucher (optional)
:type valid_until: datetime
:param block_quota: If set to true, this voucher will reserve quota for its holder
@@ -199,6 +201,14 @@ class Voucher(LoggedModel):
verbose_name=_("Redeemed"),
default=0
)
min_usages = models.PositiveIntegerField(
verbose_name=_("Minimum usages"),
help_text=_("If set to more than one, the voucher must be redeemed for this many products when it is used for "
"the first time. On later usages, it can also be used for lower numbers of products. Note that "
"this means that the total number of usages in some cases can be lower than this limit, e.g. in "
"case of cancellations."),
default=1
)
budget = models.DecimalField(
verbose_name=_("Maximum discount budget"),
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
@@ -350,6 +360,10 @@ class Voucher(LoggedModel):
'redeemed': redeemed
}
)
if data.get('max_usages', 1) < data.get('min_usages', 1):
raise ValidationError(
_('The maximum number of usages may not be lower than the minimum number of usages.'),
)
@staticmethod
def clean_subevent(data, event):
@@ -464,7 +478,7 @@ class Voucher(LoggedModel):
if quota:
raise ValidationError(_('You need to choose a specific product if you select a seat.'))
if data.get('max_usages', 1) > 1:
if data.get('max_usages', 1) > 1 or data.get('min_usages', 1) > 1:
raise ValidationError(_('Seat-specific vouchers can only be used once.'))
if item and seat.product != item:

View File

@@ -110,6 +110,11 @@ error_messages = {
'positions have been removed from your cart.'),
'price_too_high': _('The entered price is to high.'),
'voucher_invalid': _('This voucher code is not known in our database.'),
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
'matching products.'),
'voucher_min_usages_removed': _('The voucher code "%(voucher)s" can only be used if you select at least '
'%(number)s matching products. We have therefore removed some positions from '
'your cart that can no longer be purchased like this.'),
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
'voucher_redeemed_cart': _('This voucher code is currently locked since it is already contained in a cart. This '
'might mean that someone else is redeeming this voucher right now, or that you tried '
@@ -524,6 +529,15 @@ class CartManager:
voucher_use_diff[voucher] += 1
ops.append((listed_price - price_after_voucher, self.VoucherOperation(p, voucher, price_after_voucher)))
for voucher, cnt in list(voucher_use_diff.items()):
if 0 < cnt < (voucher.min_usages - voucher.redeemed):
raise CartError(
_(error_messages['voucher_min_usages']) % {
'voucher': voucher.code,
'number': (voucher.min_usages - voucher.redeemed),
}
)
# If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
# the user the most.
ops.sort(key=lambda k: k[0], reverse=True)
@@ -915,6 +929,41 @@ class CartManager:
)
return err
def _check_min_per_voucher(self):
vouchers = Counter()
for p in self.positions:
vouchers[p.voucher] += 1
for op in self._operations:
if isinstance(op, self.AddOperation):
vouchers[op.voucher] += op.count
elif isinstance(op, self.RemoveOperation):
vouchers[op.position.voucher] -= 1
err = None
for voucher, count in vouchers.items():
if not voucher or count == 0:
continue
if count < (voucher.min_usages - voucher.redeemed):
self._operations = [o for o in self._operations if not (
isinstance(o, self.AddOperation) and o.voucher.pk == voucher.pk
)]
removals = [o.position.pk for o in self._operations if isinstance(o, self.RemoveOperation)]
for p in self.positions:
if p.voucher_id == voucher.pk and p.pk not in removals:
self._operations.append(self.RemoveOperation(position=p))
err = _(error_messages['voucher_min_usages_removed']) % {
'voucher': voucher.code,
'number': (voucher.min_usages - voucher.redeemed),
}
if not err:
raise CartError(
_(error_messages['voucher_min_usages']) % {
'voucher': voucher.code,
'number': (voucher.min_usages - voucher.redeemed),
}
)
return err
def _perform_operations(self):
vouchers_ok = self._get_voucher_availability()
quotas_ok = _get_quota_availability(self._quota_diff, self.now_dt)
@@ -1171,6 +1220,7 @@ class CartManager:
err = self._delete_out_of_timeframe()
err = self.extend_expired_positions() or err
err = err or self._check_min_per_voucher()
lockfn = NoLockManager
if self._require_locking():

View File

@@ -115,6 +115,8 @@ error_messages = {
'server was too busy. Please try again.'),
'not_started': _('The booking period for this event has not yet started.'),
'ended': _('The booking period has ended.'),
'voucher_min_usages': _('The voucher code "%(voucher)s" can only be used if you select at least %(number)s '
'matching products.'),
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
'number of times allowed. We removed this item from your cart.'),
@@ -569,6 +571,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter()
q_avail = Counter()
v_avail = Counter()
v_usages = Counter()
v_budget = {}
deleted_positions = set()
seats_seen = set()
@@ -606,6 +609,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break
if cp.voucher:
v_usages[cp.voucher] += 1
if cp.voucher not in v_avail:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
@@ -717,6 +721,13 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
# Sorry, can't let you keep that!
delete(cp)
for voucher, cnt in v_usages.items():
if 0 < cnt < voucher.min_usages - voucher.redeemed:
raise OrderError(error_messages['voucher_min_usages'], {
'voucher': voucher.code,
'number': (voucher.min_usages - voucher.redeemed),
})
# Check prices
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
old_total = sum(cp.price for cp in sorted_positions)

View File

@@ -72,7 +72,7 @@ class VoucherForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
'comment', 'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
]
field_classes = {
'valid_until': SplitDateTimeField,
@@ -308,7 +308,7 @@ class VoucherBulkForm(VoucherForm):
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
'max_usages', 'min_usages', 'price_mode', 'subevent', 'show_hidden_items', 'budget'
]
field_classes = {
'valid_until': SplitDateTimeField,

View File

@@ -73,6 +73,7 @@
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.block_quota layout="control" %}
{% bootstrap_field form.allow_ignore_quota layout="control" %}
{% bootstrap_field form.min_usages layout="control" %}
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.tag layout="control" %}
{% bootstrap_field form.comment layout="control" %}

View File

@@ -85,6 +85,7 @@
<legend>{% trans "Advanced settings" %}</legend>
{% bootstrap_field form.block_quota layout="control" %}
{% bootstrap_field form.allow_ignore_quota layout="control" %}
{% bootstrap_field form.min_usages layout="control" %}
{% bootstrap_field form.budget addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.tag layout="control" %}
{% bootstrap_field form.comment layout="control" %}

View File

@@ -410,7 +410,16 @@
{% eventsignal event "pretix.presale.signals.voucher_redeem_info" voucher=voucher %}
{% if event.presale_is_running and options > 0 %}
<div class="row checkout-button-row">
<div class="col-md-4 col-md-offset-8 col-xs-12">
<div class="col-md-4 col-md-offset-8 col-xs-12 text-center">
{% if voucher.min_usages > 1 %}
<p class="text-muted">
<small>
{% blocktrans trimmed with number=voucher.min_usages %}
You need to select at least {{ number }} products.
{% endblocktrans %}
</small>
</p>
{% endif %}
<button class="btn btn-block btn-primary btn-lg" id="btn-add-to-cart" type="submit">
{% if request.event.settings.redirect_to_checkout_directly %}
{% if allfree %}

View File

@@ -66,6 +66,7 @@ TEST_VOUCHER_RES = {
'id': 1,
'code': '43K6LKM37FBVR2YG',
'max_usages': 1,
'min_usages': 1,
'redeemed': 0,
'valid_until': None,
'block_quota': False,
@@ -311,7 +312,8 @@ def test_voucher_create_full(token_client, organizer, event, item):
token_client, organizer, event,
data={
'code': 'ABCDEFGHI',
'max_usages': 1,
'max_usages': 10,
'min_usages': 10,
'valid_until': None,
'block_quota': False,
'allow_ignore_quota': False,
@@ -327,10 +329,10 @@ def test_voucher_create_full(token_client, organizer, event, item):
)
assert v.code == 'ABCDEFGHI'
assert v.max_usages == 1
assert v.max_usages == 10
assert v.min_usages == 10
assert v.redeemed == 0
assert v.valid_until is None
assert v.max_usages == 1
assert v.block_quota is False
assert v.price_mode == 'set'
assert v.value == Decimal('12.00')

View File

@@ -1281,6 +1281,35 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists())
def test_remove_voucher_min_usages(self):
with scopes_disabled():
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event,
redeemed=1, min_usages=3, max_usages=10)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket, voucher=v,
price=23, expires=now() + timedelta(minutes=10)
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket, voucher=v,
price=23, expires=now() + timedelta(minutes=10)
)
cp3 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket, voucher=v,
price=23, expires=now() + timedelta(minutes=10)
)
self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), {
'id': cp3.pk
}, follow=True)
with scopes_disabled():
self.assertEqual(2, CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count())
response = self.client.post('/%s/%s/cart/remove' % (self.orga.slug, self.event.slug), {
'id': cp2.pk
}, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn('at least 2', doc.select('.alert-danger')[0].text)
with scopes_disabled():
self.assertEqual(0, CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count())
def test_remove_min(self):
self.ticket.min_per_order = 2
self.ticket.save()
@@ -1724,6 +1753,27 @@ class CartTest(CartTestMixin, TestCase):
with scopes_disabled():
self.assertEqual(1, CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count())
def test_voucher_min_usages(self):
with scopes_disabled():
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), event=self.event, redeemed=1,
min_usages=5, max_usages=10)
response = 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)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertIn('select at least 4', doc.select('.alert-danger')[0].text)
with scopes_disabled():
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists())
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '4',
'_voucher_code': v.code,
}, follow=True)
doc = BeautifulSoup(response.rendered_content, "lxml")
self.assertFalse(doc.select('.alert-danger'))
with scopes_disabled():
self.assertEqual(4, CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count())
def test_require_voucher(self):
with scopes_disabled():
v = Voucher.objects.create(quota=self.quota_shirts, event=self.event)
@@ -2049,6 +2099,45 @@ class CartTest(CartTestMixin, TestCase):
assert cp2.voucher == v
assert cp2.price == Decimal('4.00')
def test_voucher_apply_min_usages(self):
with scopes_disabled():
cp1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
cp2 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
price=150, listed_price=150, price_after_voucher=150, expires=now() + timedelta(minutes=10)
)
v = Voucher.objects.create(
event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('4.00'), max_usages=100,
min_usages=4, redeemed=1
)
response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
'voucher': v.code,
}, follow=True)
assert 'alert-danger' in response.rendered_content
assert 'at least 3' in response.rendered_content
with scopes_disabled():
cp1.refresh_from_db()
cp2.refresh_from_db()
assert not cp1.voucher
assert not cp2.voucher
v.redeemed = 2
v.save()
response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
'voucher': v.code,
}, follow=True)
assert 'alert-success' in response.rendered_content
with scopes_disabled():
cp1.refresh_from_db()
cp2.refresh_from_db()
assert cp1.voucher == v
assert cp1.price == Decimal('4.00')
assert cp2.voucher == v
assert cp2.price == Decimal('4.00')
def test_voucher_apply_only_one_per_line(self):
with scopes_disabled():
cp1 = CartPosition.objects.create(

View File

@@ -2305,6 +2305,34 @@ class CheckoutTestCase(BaseCheckoutTestCase, TestCase):
with scopes_disabled():
assert CartPosition.objects.filter(cart_id=self.session_key).count() == 1
def test_voucher_min_usages(self):
with scopes_disabled():
v = Voucher.objects.create(item=self.ticket, value=Decimal('12.00'), price_mode='set', event=self.event,
valid_until=now() + timedelta(days=2), max_usages=10, redeemed=1,
min_usages=3)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=12, expires=now() + timedelta(minutes=10), voucher=v
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
self.assertIn("at least 2", doc.select(".alert-danger")[0].text)
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=12, expires=now() + timedelta(minutes=10), voucher=v
)
self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug)) # required for session['shown_total']
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.content.decode(), "lxml")
print(doc)
self.assertEqual(len(doc.select(".thank-you")), 1)
with scopes_disabled():
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 2)
def test_voucher_ignore_quota(self):
self.quota_tickets.size = 0
self.quota_tickets.save()