diff --git a/doc/api/resources/vouchers.rst b/doc/api/resources/vouchers.rst index 28e23fbbb..0dd68cee8 100644 --- a/doc/api/resources/vouchers.rst +++ b/doc/api/resources/vouchers.rst @@ -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 diff --git a/src/pretix/api/serializers/voucher.py b/src/pretix/api/serializers/voucher.py index 59e1a8479..74a90ada7 100644 --- a/src/pretix/api/serializers/voucher.py +++ b/src/pretix/api/serializers/voucher.py @@ -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') diff --git a/src/pretix/base/migrations/0223_voucher_min_usages.py b/src/pretix/base/migrations/0223_voucher_min_usages.py new file mode 100644 index 000000000..e093d9698 --- /dev/null +++ b/src/pretix/base/migrations/0223_voucher_min_usages.py @@ -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), + ), + ] diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 94e3dd3cb..6d326ebea 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -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: diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 9a97328ff..507498950 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -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(): diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index be4a1418f..5b1a43608 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -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) diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index dbe05b050..b4c98b1c3 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -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, diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html index 6ccbaf0c2..d5489b3a3 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html @@ -73,6 +73,7 @@ {% trans "Advanced settings" %} {% 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" %} diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html index 34e943888..92fc3206f 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/detail.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/detail.html @@ -85,6 +85,7 @@ {% trans "Advanced settings" %} {% 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" %} diff --git a/src/pretix/presale/templates/pretixpresale/event/voucher.html b/src/pretix/presale/templates/pretixpresale/event/voucher.html index b2d8a3c56..d7110647c 100644 --- a/src/pretix/presale/templates/pretixpresale/event/voucher.html +++ b/src/pretix/presale/templates/pretixpresale/event/voucher.html @@ -410,7 +410,16 @@ {% eventsignal event "pretix.presale.signals.voucher_redeem_info" voucher=voucher %} {% if event.presale_is_running and options > 0 %}
-
+
+ {% if voucher.min_usages > 1 %} +

+ + {% blocktrans trimmed with number=voucher.min_usages %} + You need to select at least {{ number }} products. + {% endblocktrans %} + +

+ {% endif %}