diff --git a/src/pretix/base/migrations/0020_auto_20160418_2106.py b/src/pretix/base/migrations/0020_auto_20160418_2106.py new file mode 100644 index 0000000000..d76121181b --- /dev/null +++ b/src/pretix/base/migrations/0020_auto_20160418_2106.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-18 21:06 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0019_auto_20160326_1139'), + ] + + operations = [ + migrations.AddField( + model_name='voucher', + name='quota', + field=models.ForeignKey(blank=True, help_text='If enabled, the voucher is valid for any product affected by this quota.', null=True, on_delete=django.db.models.deletion.CASCADE, related_name='quota', to='pretixbase.Quota', verbose_name='Quota'), + ), + migrations.AlterField( + model_name='questionanswer', + name='options', + field=models.ManyToManyField(blank=True, related_name='answers', to='pretixbase.QuestionOption'), + ), + ] diff --git a/src/pretix/base/migrations/0021_auto_20160418_2117.py b/src/pretix/base/migrations/0021_auto_20160418_2117.py new file mode 100644 index 0000000000..6157f931de --- /dev/null +++ b/src/pretix/base/migrations/0021_auto_20160418_2117.py @@ -0,0 +1,21 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-18 21:17 +from __future__ import unicode_literals + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0020_auto_20160418_2106'), + ] + + operations = [ + migrations.AlterField( + model_name='voucher', + name='item', + field=models.ForeignKey(blank=True, help_text="This product is added to the user's cart if the voucher is redeemed.", null=True, on_delete=django.db.models.deletion.CASCADE, related_name='vouchers', to='pretixbase.Item', verbose_name='Product'), + ), + ] diff --git a/src/pretix/base/migrations/0022_merge.py b/src/pretix/base/migrations/0022_merge.py new file mode 100644 index 0000000000..bc90710ec5 --- /dev/null +++ b/src/pretix/base/migrations/0022_merge.py @@ -0,0 +1,16 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.9.2 on 2016-04-23 09:44 +from __future__ import unicode_literals + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0020_auto_20160421_1943'), + ('pretixbase', '0021_auto_20160418_2117'), + ] + + operations = [ + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 3e239eb61a..511d2dec95 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -513,10 +513,9 @@ class Quota(LoggedModel): def count_blocking_vouchers(self) -> int: from pretix.base.models import Voucher return Voucher.objects.filter( - Q(item__quotas__in=[self]) & Q(block_quota=True) & Q(redeemed=False) & - self._position_lookup + Q(Q(self._position_lookup) | Q(quota=self)) ).distinct().count() def count_in_cart(self) -> int: @@ -546,8 +545,8 @@ class Quota(LoggedModel): def _position_lookup(self) -> Q: return ( ( # Orders for items which do not have any variations - Q(variation__isnull=True) - & Q(item__quotas__in=[self]) + Q(variation__isnull=True) & + Q(item__quotas__in=[self]) ) | ( # Orders for items which do have any variations Q(variation__quotas__in=[self]) ) diff --git a/src/pretix/base/models/vouchers.py b/src/pretix/base/models/vouchers.py index 2d1b57759a..160b8e73db 100644 --- a/src/pretix/base/models/vouchers.py +++ b/src/pretix/base/models/vouchers.py @@ -1,11 +1,13 @@ import random +from decimal import Decimal +from django.core.exceptions import ValidationError from django.db import models from django.utils.translation import ugettext_lazy as _ from .base import LoggedModel from .event import Event -from .items import Item, ItemVariation +from .items import Item, ItemVariation, Quota from .orders import CartPosition, OrderPosition @@ -59,6 +61,7 @@ class Voucher(LoggedModel): item = models.ForeignKey( Item, related_name='vouchers', verbose_name=_("Product"), + null=True, blank=True, help_text=_( "This product is added to the user's cart if the voucher is redeemed." ) @@ -71,6 +74,14 @@ class Voucher(LoggedModel): "This variation of the product select above is being used." ) ) + quota = models.ForeignKey( + Quota, related_name='quota', + null=True, blank=True, + verbose_name=_("Quota"), + help_text=_( + "If enabled, the voucher is valid for any product affected by this quota." + ) + ) class Meta: verbose_name = _("Voucher") @@ -80,6 +91,21 @@ class Voucher(LoggedModel): def __str__(self): return self.code + def clean(self): + super().clean() + if self.quota: + if self.item: + raise ValidationError(_('You cannot select a quota and a specific product at the same time.')) + elif self.item: + if self.variation and (not self.item or not self.item.has_variations): + raise ValidationError(_('You cannot select a variation without having selected a product that provides ' + 'variations.')) + if self.item.has_variations and not self.variation and self.block_quota: + raise ValidationError(_('You can only block quota if you specify a specific product variation. ' + 'Otherwise it might be unclear which quotas to block.')) + else: + raise ValidationError(_('You need to specify either a quota or a product.')) + def save(self, *args, **kwargs): self.code = self.code.upper() super().save(*args, **kwargs) diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 2e45cd9838..5074d9c237 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -31,7 +31,8 @@ error_messages = { '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_expired': _('This voucher is expired.'), + 'voucher_invalid_item': _('This voucher is not valid for this item.'), } @@ -44,7 +45,7 @@ def _extend_existing(event: Event, cart_id: str, expiry: datetime) -> None: ).update(expires=expiry) -def _re_add_expired_positions(items: List[CartPosition], event: Event, cart_id: str) -> List[CartPosition]: +def _re_add_expired_positions(items: List[dict], event: Event, cart_id: str) -> List[CartPosition]: positions = set() # For items that are already expired, we have to delete and re-add them, as they might # be no longer available or prices might have changed. Sorry! @@ -52,7 +53,14 @@ def _re_add_expired_positions(items: List[CartPosition], event: Event, cart_id: Q(cart_id=cart_id) & Q(event=event) & Q(expires__lte=now()) ) for cp in expired: - items.insert(0, (cp.item_id, cp.variation_id, 1, cp.price, cp)) + items.insert(0, { + 'item': cp.item_id, + 'variation': cp.variation_id, + 'count': 1, + 'price': cp.price, + 'cp': cp, + 'voucher': cp.voucher + }) positions.add(cp) return positions @@ -70,17 +78,17 @@ def _check_date(event: Event) -> None: raise CartError(error_messages['ended']) -def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int, Optional[str]]], +def _add_new_items(event: Event, items: List[dict], cart_id: str, expiry: datetime) -> Optional[str]: err = None # Fetch items from the database - items_query = Item.objects.filter(event=event, id__in=[i[0] for i in items]).prefetch_related( + items_query = Item.objects.filter(event=event, id__in=[i['item'] for i in items]).prefetch_related( "quotas") items_cache = {i.id: i for i in items_query} variations_query = ItemVariation.objects.filter( item__event=event, - id__in=[i[1] for i in items if i[1] is not None] + id__in=[i['variation'] for i in items if i['variation'] is not None] ).select_related("item", "item__event").prefetch_related("quotas") variations_cache = {v.id: v for v in variations_query} @@ -88,46 +96,75 @@ def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int, Opti # Check whether the specified items are part of what we just fetched from the database # If they are not, the user supplied item IDs which either do not exist or belong to # a different event - if i[0] not in items_cache or (i[1] is not None and i[1] not in variations_cache): + if i['item'] not in items_cache or (i['variation'] is not None and i['variation'] not in variations_cache): err = err or error_messages['not_for_sale'] continue - item = items_cache[i[0]] - variation = variations_cache[i[1]] if i[1] is not None else None + item = items_cache[i['item']] + variation = variations_cache[i['variation']] if i['variation'] is not None else None + + # Check whether a voucher has been provided + voucher = None + if i.get('voucher'): + try: + voucher = Voucher.objects.get(code=i.get('voucher'), event=event) + if voucher.redeemed: + return error_messages['voucher_redeemed'] + if voucher.valid_until is not None and voucher.valid_until < now(): + return error_messages['voucher_expired'] + if voucher.item and voucher.item.pk != item.pk: + return error_messages['voucher_invalid_item'] + if voucher.variation and (not variation or variation.pk != voucher.variation.pk): + return error_messages['voucher_invalid_item'] + doubleuse = CartPosition.objects.filter(voucher=voucher, cart_id=cart_id, event=event) + if 'cp' in i: + doubleuse = doubleuse.exclude(pk=i['cp'].pk) + if doubleuse.exists(): + return error_messages['voucher_redeemed'] + except Voucher.DoesNotExist: + return error_messages['voucher_invalid'] # Fetch all quotas. If there are no quotas, this item is not allowed to be sold. quotas = list(item.quotas.all()) if variation is None else list(variation.quotas.all()) + if voucher and voucher.quota and voucher.quota.pk not in [q.pk for q in quotas]: + return error_messages['voucher_invalid_item'] + if len(quotas) == 0 or not item.is_available(): err = err or error_messages['unavailable'] continue - # Check that all quotas allow us to buy i[2] instances of the object - quota_ok = i[2] - for quota in quotas: - avail = quota.availability() - if avail[1] is not None and avail[1] < i[2]: - # This quota is not available or less than i[2] items are left, so we have to - # reduce the number of bought items - if avail[0] != Quota.AVAILABILITY_OK: - err = err or error_messages['unavailable'] - else: - err = err or error_messages['in_part'] - quota_ok = min(quota_ok, avail[1]) + # Check that all quotas allow us to buy i['count'] instances of the object + quota_ok = i['count'] + if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota): + for quota in quotas: + avail = quota.availability() + if avail[1] is not None and avail[1] < i['count']: + # This quota is not available or less than i['count'] items are left, so we have to + # reduce the number of bought items + if avail[0] != Quota.AVAILABILITY_OK: + err = err or error_messages['unavailable'] + else: + err = err or error_messages['in_part'] + quota_ok = min(quota_ok, avail[1]) - price = item.default_price if variation is None else ( - variation.default_price if variation.default_price is not None else item.default_price) - if item.free_price and len(i) > 3 and i[3]: - custom_price = i[3] + if voucher and voucher.price is not None: + price = voucher.price + else: + price = item.default_price if variation is None else ( + variation.default_price if variation.default_price is not None else item.default_price) + + if item.free_price and 'price' in i and i['price'] is not None and i['price'] != "": + custom_price = i['price'] if not isinstance(custom_price, Decimal): custom_price = Decimal(custom_price.replace(",", ".")) price = max(custom_price, price) # Create a CartPosition for as much items as we can for k in range(quota_ok): - if len(i) > 4 and i[2] == 1: + if 'cp' in i and i['count'] == 1: # Recreating - cp = i[4] + cp = i['cp'] cp.expires = expiry cp.price = price cp.save() @@ -136,44 +173,16 @@ def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int, Opti event=event, item=item, variation=variation, price=price, expires=expiry, - cart_id=cart_id + cart_id=cart_id, voucher=voucher ) return err -def _add_voucher(event: Event, voucher: str, expiry: datetime, cart_id: str): - try: - v = Voucher.objects.get(code=voucher, event=event) - if v.redeemed: - raise CartError(error_messages['voucher_redeemed']) - if v.valid_until is not None and v.valid_until < now(): - raise CartError(error_messages['voucher_expired']) - - quotas = list(v.item.quotas.all()) - if len(quotas) == 0 or not v.item.is_available(): - raise CartError(error_messages['unavailable']) - - if not v.allow_ignore_quota and not v.block_quota: - for quota in quotas: - avail = quota.availability() - if avail[1] is not None and avail[1] < 1: - raise CartError(error_messages['unavailable']) - - CartPosition.objects.create( - event=event, item=v.item, variation=v.variation, - price=v.price if v.price is not None else v.item.default_price, - expires=expiry, cart_id=cart_id, voucher=v - ) - except Voucher.DoesNotExist: - raise CartError(error_messages['voucher_invalid']) - - -def _add_items_to_cart(event: Event, items: List[Tuple[int, Optional[int], int, Optional[str]]], cart_id: str=None, - voucher: str=None) -> None: +def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None: with event.lock(): _check_date(event) existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count() - if sum(i[2] for i in items) + existing > int(event.settings.max_items_per_order): + if sum(i['count'] for i in items) + existing > int(event.settings.max_items_per_order): # TODO: i18n plurals raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,)) @@ -186,43 +195,37 @@ def _add_items_to_cart(event: Event, items: List[Tuple[int, Optional[int], int, _delete_expired(expired) if err: raise CartError(err) - elif not voucher: - raise CartError(error_messages['empty']) - - if voucher: - _add_voucher(event, voucher, expiry, cart_id) -def add_items_to_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]], cart_id: str=None, - voucher: str=None) -> None: +def add_items_to_cart(event: int, items: List[dict], cart_id: str=None) -> None: """ Adds a list of items to a user's cart. :param event: The event ID in question - :param items: A list of tuple of the form (item id, variation id or None, number, custom_price) + :param items: A list of tuple of the form (item id, variation id or None, number, custom_price, voucher) :param session: Session ID of a guest :param coupon: A coupon that should also be reeemed :raises CartError: On any error that occured """ event = Event.objects.get(id=event) try: - _add_items_to_cart(event, items, cart_id, voucher) + _add_items_to_cart(event, items, cart_id) except EventLock.LockTimeoutException: raise CartError(error_messages['busy']) -def _remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]], - cart_id: str) -> None: +def _remove_items_from_cart(event: Event, items: List[dict], cart_id: str) -> None: with event.lock(): - for item, variation, cnt, price in items: - cw = Q(cart_id=cart_id) & Q(item_id=item) & Q(event=event) - if variation: - cw &= Q(variation_id=variation) + for i in items: + cw = Q(cart_id=cart_id) & Q(item_id=i['item']) & Q(event=event) + if i['variation']: + cw &= Q(variation_id=i['variation']) else: cw &= Q(variation__isnull=True) # Prefer to delete positions that have the same price as the one the user clicked on, after thet # prefer the most expensive ones. - if price: - correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(price.replace(",", ".")))[:cnt] + cnt = i['count'] + if i['price']: + correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt] for cp in correctprice: cp.delete() cnt -= len(correctprice) @@ -231,8 +234,7 @@ def _remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], in cp.delete() -def remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]], - cart_id: str=None) -> None: +def remove_items_from_cart(event: int, items: List[dict], cart_id: str=None) -> None: """ Removes a list of items from a user's cart. :param event: The event ID in question @@ -250,20 +252,18 @@ if settings.HAS_CELERY: from pretix.celery import app @app.task(bind=True, max_retries=5, default_retry_delay=1) - def add_items_to_cart_task(self, event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]], - cart_id: str, voucher: str=None): + def add_items_to_cart_task(self, event: int, items: List[dict], cart_id: str): event = Event.objects.get(id=event) try: try: - _add_items_to_cart(event, items, cart_id, voucher) + _add_items_to_cart(event, items, cart_id) except EventLock.LockTimeoutException: self.retry(exc=CartError(error_messages['busy'])) except CartError as e: return e @app.task(bind=True, max_retries=5, default_retry_delay=1) - def remove_items_from_cart_task(self, event: int, items: List[Tuple[int, Optional[int], int]], - cart_id: str): + def remove_items_from_cart_task(self, event: int, items: List[dict], cart_id: str): event = Event.objects.get(id=event) try: try: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 926fcc9469..151752b730 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -158,6 +158,7 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]): err = None _check_date(event) + voucherids = set() for i, cp in enumerate(positions): if not cp.item.active: err = err or error_messages['unavailable'] @@ -166,11 +167,13 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]): quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) if cp.voucher: - if cp.voucher.redeemed: + if cp.voucher.redeemed or cp.voucher_id in voucherids: err = err or error_messages['voucher_redeemed'] + cp.delete() # Sorry! But you should have never gotten into this state at all. continue + voucherids.add(cp.voucher_id) - if cp.expires >= dt: + if cp.expires >= dt and not cp.voucher: # Other checks are not necessary continue @@ -183,7 +186,7 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]): continue if cp.voucher: - if cp.voucher.valid_until < now(): + if cp.voucher.valid_until and cp.voucher.valid_until < now(): err = err or error_messages['voucher_expired'] continue if cp.voucher.price is not None: diff --git a/src/pretix/control/forms/vouchers.py b/src/pretix/control/forms/vouchers.py index 156e979b24..3cb4527d0e 100644 --- a/src/pretix/control/forms/vouchers.py +++ b/src/pretix/control/forms/vouchers.py @@ -2,7 +2,7 @@ from django import forms from django.utils.translation import ugettext_lazy as _ from pretix.base.forms import I18nModelForm -from pretix.base.models import Item, ItemVariation, Voucher +from pretix.base.models import Item, ItemVariation, Quota, Voucher class VoucherForm(I18nModelForm): @@ -29,6 +29,8 @@ class VoucherForm(I18nModelForm): initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk) elif instance.item: initial['itemvar'] = str(instance.item.pk) + elif instance.quota: + initial['itemvar'] = 'q-%d' % instance.quota.pk except Item.DoesNotExist: pass super().__init__(*args, **kwargs) @@ -36,22 +38,39 @@ class VoucherForm(I18nModelForm): for i in self.instance.event.items.prefetch_related('variations').all(): variations = list(i.variations.all()) if variations: + choices.append((str(i.pk), _('{product} – Any variation').format(product=i.name))) for v in variations: choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (i.name, v.value))) else: choices.append((str(i.pk), i.name)) + for q in self.instance.event.quotas.all(): + choices.append(('q-%d' % q.pk, 'Any product in quota "{quota}"'.format(quota=q))) self.fields['itemvar'].choices = choices - def save(self, commit=True): - if '-' in self.cleaned_data['itemvar']: - itemid, varid = self.cleaned_data['itemvar'].split('-') + def clean(self): + data = super().clean() + itemid = quotaid = None + if self.data['itemvar'].startswith('q-'): + quotaid = self.data['itemvar'][2:] + elif '-' in self.data['itemvar']: + itemid, varid = self.data['itemvar'].split('-') else: - itemid, varid = self.cleaned_data['itemvar'], None - self.instance.item = Item.objects.get(pk=itemid, event=self.instance.event) - if varid: - self.instance.variation = ItemVariation.objects.get(pk=varid, item=self.instance.item) + itemid, varid = self.data['itemvar'], None + + if itemid: + self.instance.item = Item.objects.get(pk=itemid, event=self.instance.event) + if varid: + self.instance.variation = ItemVariation.objects.get(pk=varid, item=self.instance.item) + else: + self.instance.variation = None + self.instance.quota = None else: + self.instance.quota = Quota.objects.get(pk=quotaid, event=self.instance.event) + self.instance.item = None self.instance.variation = None + return data + + def save(self, commit=True): super().save(commit) return ['item'] diff --git a/src/pretix/control/templates/pretixcontrol/vouchers/index.html b/src/pretix/control/templates/pretixcontrol/vouchers/index.html index 6f558f6a69..3dc6e2d48f 100644 --- a/src/pretix/control/templates/pretixcontrol/vouchers/index.html +++ b/src/pretix/control/templates/pretixcontrol/vouchers/index.html @@ -27,7 +27,15 @@