diff --git a/src/pretix/base/migrations/0047_auto_20161126_1300.py b/src/pretix/base/migrations/0047_auto_20161126_1300.py new file mode 100644 index 0000000000..87e708639b --- /dev/null +++ b/src/pretix/base/migrations/0047_auto_20161126_1300.py @@ -0,0 +1,38 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.10.2 on 2016-11-26 13:00 +from __future__ import unicode_literals + +import django.core.validators +from django.db import migrations, models + +import pretix.base.validators + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0046_order_meta_info'), + ] + + operations = [ + migrations.AddField( + model_name='voucher', + name='max_usages', + field=models.PositiveIntegerField(default=1, help_text='Number of times this voucher can be redeemed.', verbose_name='Maximum usages'), + ), + migrations.AlterField( + model_name='event', + name='slug', + field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Slug'), + ), + migrations.AlterField( + model_name='organizer', + name='slug', + field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Slug'), + ), + migrations.AlterField( + model_name='voucher', + name='redeemed', + field=models.PositiveIntegerField(default=0, verbose_name='Redeemed'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 83cc3e623c..b0322271f6 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -4,8 +4,9 @@ from datetime import datetime from decimal import Decimal from typing import Tuple +from django.conf import settings from django.db import models -from django.db.models import Q +from django.db.models import F, Func, Q, Sum from django.utils.functional import cached_property from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ @@ -577,12 +578,18 @@ class Quota(LoggedModel): from pretix.base.models import Voucher now_dt = now_dt or now() + if 'sqlite3' in settings.DATABASES['default']['ENGINE']: + func = 'MAX' + else: + func = 'GREATEST' + return Voucher.objects.filter( Q(block_quota=True) & - Q(redeemed=False) & Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) & Q(Q(self._position_lookup) | Q(quota=self)) - ).values('id').distinct().count() + ).values('id').aggregate( + free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func)) + )['free'] or 0 def count_in_cart(self, now_dt: datetime=None) -> int: from pretix.base.models import CartPosition @@ -617,9 +624,9 @@ class Quota(LoggedModel): return ( ( # Orders for items which do not have any variations Q(variation__isnull=True) & - Q(item__quotas__in=[self]) + Q(item__quotas=self) ) | ( # Orders for items which do have any variations - Q(variation__quotas__in=[self]) + Q(variation__quotas=self) ) ) diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 965690f158..0e3a4cf41f 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -7,6 +7,7 @@ from typing import List, Union import pytz from django.conf import settings from django.db import models +from django.db.models import F from django.utils.crypto import get_random_string from django.utils.timezone import make_aware, now from django.utils.translation import ugettext_lazy as _ @@ -459,6 +460,8 @@ class OrderPosition(AbstractPosition): @classmethod def transform_cart_positions(cls, cp: List, order) -> list: + from . import Voucher + ops = [] for cartpos in cp: op = OrderPosition(order=order) @@ -471,8 +474,7 @@ class OrderPosition(AbstractPosition): answ.cartposition = None answ.save() if cartpos.voucher: - cartpos.voucher.redeemed = True - cartpos.voucher.save() + Voucher.objects.filter(pk=cartpos.voucher.pk).update(redeemed=F('redeemed') + 1) cartpos.delete() return ops diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 4da4aa6011..ad1605a035 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -30,7 +30,9 @@ class Voucher(LoggedModel): :type event: Event :param code: The secret voucher code :type code: str - :param redeemed: Whether or not this voucher has already been redeemed + :param max_usages: The number of times this voucher can be redeemed + :type max_usages: int + :param redeemed: The number of times this voucher already has been redeemed :type redeemed: bool :param valid_until: The expiration date of this voucher (optional) :type valid_until: datetime @@ -68,10 +70,14 @@ class Voucher(LoggedModel): max_length=255, default=generate_code, db_index=True, ) - redeemed = models.BooleanField( + max_usages = models.PositiveIntegerField( + verbose_name=_("Maximum usages"), + help_text=_("Number of times this voucher can be redeemed."), + default=1 + ) + redeemed = models.PositiveIntegerField( verbose_name=_("Redeemed"), - default=False, - db_index=True + default=0 ) valid_until = models.DateTimeField( blank=True, null=True, db_index=True, @@ -197,7 +203,7 @@ class Voucher(LoggedModel): Returns True if a voucher has not yet been redeemed, but is still within its validity (if valid_until is set). """ - if self.redeemed: + if self.redeemed >= self.max_usages: return False if self.valid_until and self.valid_until < now(): return False diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index be17e12a6a..bc54f2d440 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -33,7 +33,8 @@ error_messages = { 'ended': _('The presale period has ended.'), 'price_too_high': _('The entered price is to high.'), 'voucher_invalid': _('This voucher code is not known in our database.'), - 'voucher_redeemed': _('This voucher code has already been used and can only be used once.'), + 'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'), + 'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'), 'voucher_double': _('You already used this voucher code. Remove the associated line from your ' 'cart if you want to use it for a different product.'), 'voucher_expired': _('This voucher is expired.'), @@ -114,17 +115,26 @@ def _add_new_items(event: Event, items: List[dict], if i.get('voucher'): try: voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event) - if voucher.redeemed: + if voucher.redeemed >= voucher.max_usages: return error_messages['voucher_redeemed'] if voucher.valid_until is not None and voucher.valid_until < now_dt: return error_messages['voucher_expired'] if not voucher.applies_to(item, variation): return error_messages['voucher_invalid_item'] - doubleuse = CartPosition.objects.filter(voucher=voucher, cart_id=cart_id, event=event) + + redeemed_in_carts = CartPosition.objects.filter( + Q(voucher=voucher) & Q(event=event) & + (Q(expires__gte=now_dt) | Q(cart_id=cart_id)) + ) if 'cp' in i: - doubleuse = doubleuse.exclude(pk=i['cp'].pk) - if doubleuse.exists(): - return error_messages['voucher_double'] + redeemed_in_carts = redeemed_in_carts.exclude(pk=i['cp'].pk) + v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count() + + if v_avail < 1: + return error_messages['voucher_redeemed'] + if i['count'] > v_avail: + return error_messages['voucher_redeemed_partial'] % v_avail + except Voucher.DoesNotExist: return error_messages['voucher_invalid'] diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 46d899b365..b1b76124b0 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -8,6 +8,7 @@ from typing import List, Optional import pytz from celery.exceptions import MaxRetriesExceededError from django.db import transaction +from django.db.models import F, Q from django.dispatch import receiver from django.utils.formats import date_format from django.utils.timezone import make_aware, now @@ -18,7 +19,7 @@ from pretix.base.i18n import ( ) from pretix.base.models import ( CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota, - User, + User, Voucher, ) from pretix.base.models.orders import InvoiceAddress from pretix.base.payment import BasePaymentProvider @@ -46,11 +47,15 @@ error_messages = { 'server was too busy. Please try again.'), 'not_started': _('The presale period for this event has not yet started.'), 'ended': _('The presale period has ended.'), - 'voucher_invalid': _('This voucher code is not known in our database.'), - 'voucher_redeemed': _('This voucher code has already been used an can only be used once.'), - 'voucher_expired': _('This voucher is expired.'), - 'voucher_invalid_item': _('This voucher is not valid for this item.'), - 'voucher_required': _('You need a valid voucher code to order one of the products in your cart.'), + '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.'), + 'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item ' + 'from your cart.'), + 'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We ' + 'removed this item from your cart.'), + 'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this ' + 'item from your cart.'), } logger = logging.getLogger(__name__) @@ -163,8 +168,7 @@ def _cancel_order(order, user=None): for position in order.positions.all(): if position.voucher: - position.voucher.redeemed = False - position.voucher.save() + Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1) return order @@ -184,7 +188,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio err = None _check_date(event, now_dt) - voucherids = set() for i, cp in enumerate(positions): if not cp.item.active or (cp.variation and not cp.variation.active): err = err or error_messages['unavailable'] @@ -193,11 +196,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) if cp.voucher: - if cp.voucher.redeemed or cp.voucher_id in voucherids: + redeemed_in_carts = CartPosition.objects.filter( + Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt) + ).exclude(pk=cp.pk) + v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count() + if v_avail < 1: err = err or error_messages['voucher_redeemed'] - cp.delete() # Sorry! But you should have never gotten into this state at all. + cp.delete() # Sorry! continue - voucherids.add(cp.voucher_id) if cp.item.require_voucher and cp.voucher is None: cp.delete() @@ -225,6 +231,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio if cp.voucher: if cp.voucher.valid_until and cp.voucher.valid_until < now_dt: err = err or error_messages['voucher_expired'] + cp.delete() continue if cp.voucher.price is not None: price = cp.voucher.price diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 38f8b8f51f..5ceadeefc4 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -23,7 +23,7 @@ class VoucherForm(I18nModelForm): localized_fields = '__all__' fields = [ 'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', - 'comment' + 'comment', 'max_usages' ] widgets = { 'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), @@ -81,11 +81,20 @@ class VoucherForm(I18nModelForm): self.instance.item = None self.instance.variation = None + if data['max_usages'] < self.instance.redeemed: + raise ValidationError( + _('This voucher has already been redeemed %(redeemed)s times. You cannot reduce the maximum number of ' + 'usages below this number.'), + params={ + 'redeemed': self.instance.redeemed + } + ) + if 'codes' in data: data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a] - cnt = len(data['codes']) + cnt = len(data['codes']) * data['max_usages'] else: - cnt = 1 + cnt = data['max_usages'] if self._clean_quota_needs_checking(data): self._clean_quota_check(data, cnt) @@ -178,11 +187,18 @@ class VoucherBulkForm(VoucherForm): model = Voucher localized_fields = '__all__' fields = [ - 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment' + 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment', + 'max_usages' ] widgets = { 'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}), } + labels = { + 'max_usages': _('Maximum usages per voucher') + } + help_texts = { + 'max_usages': _('Number of times times EACH of these vouchers can be redeemed.') + } def clean(self): data = super().clean() diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html index a69f70b6dd..91ee271446 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/bulk.html @@ -26,6 +26,7 @@ {% bootstrap_field form.codes layout="horizontal" %} + {% bootstrap_field form.max_usages layout="horizontal" %}