forked from CGM_Public/pretix_original
Voucher: Add min_usages parameter (#2853)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
18
src/pretix/base/migrations/0223_voucher_min_usages.py
Normal file
18
src/pretix/base/migrations/0223_voucher_min_usages.py
Normal 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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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" %}
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user