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 @@ {% if v.redeemed %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %} {{ v.valid_until|date }} - {{ v.item }} + + {% if v.item %} + {{ v.item }} + {% else %} + {% blocktrans trimmed with quota=v.quota.name %} + Any product in quota "{{ quota }}" + {% endblocktrans %} + {% endif %} + diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 9d903873bb..17708b8994 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -189,27 +189,6 @@ {% endfor %} {% if event.presale_is_running %} - {% if vouchers_exist %} -
-
-
- - -
- - -
-
- -
-
-
- {% endif %}
+
+
+
+ + {% endif %} {% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/voucher.html b/src/pretix/presale/templates/pretixpresale/event/voucher.html new file mode 100644 index 0000000000..4167a4af56 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/voucher.html @@ -0,0 +1,154 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load eventurl %} +{% load thumbnail %} +{% block title %}{% trans "Voucher redemption" %}{% endblock %} + +{% block content %} +

{% trans "Voucher redemption" %}

+

+ {% blocktrans trimmed %} + You entered a voucher code that allows you to buy one of the following products at the specified price: + {% endblocktrans %} +

+ {% if event.presale_is_running or event.settings.show_items_outside_presale_period %} +
+ {% csrf_token %} + + {% for tup in items_by_category %} +
+ {% if tup.0 %}

{{ tup.0.name }}

{% endif %} + {% for item in tup.1 %} + {% if item.has_variations %} +
+
+
+ {% if item.picture %} + + {{ item.name }} + + {% endif %} + {{ item.name }} + {% if item.description %}

{{ item.description }}

{% endif %} +
+
+ {% if item.min_price != item.max_price or item.free_price %} + {% blocktrans trimmed with minprice=item.min_price|floatformat:2 currency=event.currency %} + from {{ currency }} {{ minprice }} + {% endblocktrans %} + {% else %} + {{ event.currency }} {{ item.min_price|floatformat:2 }} + {% endif %} +
+
+
+
+
+
+ {% for var in item.available_variations %} +
+
+ {{ var }} +
+
+ {% if item.free_price %} +
+ {{ event.currency }} + +
+ {% else %} + {{ event.currency }} {{ var.price|floatformat:2 }} + {% endif %} + {% if item.tax_rate %} + {% blocktrans trimmed with rate=item.tax_rate %} + incl. {{ rate }}% taxes + {% endblocktrans %} + {% endif %} +
+ {% if var.cached_availability.0 == 100 %} +
+ +
+ {% else %} + {% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 %} + {% endif %} +
+
+ {% endfor %} +
+
+ {% else %} +
+
+ {% if item.picture %} + + {{ item.name }} + + {% endif %} + {{ item.name }} + {% if item.description %} +

{{ item.description }}

{% endif %} +
+
+ {% if item.free_price %} +
+ {{ event.currency }} + +
+ {% else %} + {{ event.currency }} {{ item.price|floatformat:2 }} + {% endif %} + {% if item.tax_rate %} + {% blocktrans trimmed with rate=item.tax_rate %} + incl. {{ rate }}% taxes + {% endblocktrans %} + {% endif %} +
+ {% if item.cached_availability.0 == 100 %} +
+ +
+ {% else %} + {% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 %} + {% endif %} +
+
+ {% endif %} + {% endfor %} +
+ {% endfor %} + {% if event.presale_is_running %} +
+
+ +
+
+
+ {% endif %} +
+ {% endif %} +{% endblock %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 5f31d9bfe1..44792c7f00 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -14,6 +14,8 @@ event_patterns = [ url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'), url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'), url(r'^checkout/start$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'), + url(r'^redeem$', pretix.presale.views.cart.RedeemView.as_view(), + name='event.redeem'), url(r'^checkout/(?P[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout'), url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/$', pretix.presale.views.order.OrderDetails.as_view(), diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 1035228916..ee59cbd19c 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -1,15 +1,19 @@ from django.contrib import messages +from django.db.models import Count, Q from django.http import JsonResponse from django.shortcuts import redirect +from django.utils.timezone import now from django.utils.translation import ugettext as _ -from django.views.generic import View +from django.views.generic import TemplateView, View +from pretix.base.models import Quota, Voucher from pretix.base.services.cart import ( CartError, add_items_to_cart, remove_items_from_cart, ) from pretix.multidomain.urlreverse import eventreverse from pretix.presale.views import EventViewMixin from pretix.presale.views.async import AsyncAction +from pretix.presale.views.event import item_group_by_category class CartActionMixin: @@ -26,30 +30,58 @@ class CartActionMixin: def get_error_url(self): return self.get_next_url() - def _items_from_post_data(self, warn=True): + def _items_from_post_data(self): """ Parses the POST data and returns a list of tuples in the form (item id, variation id or None, number) """ + + # Compatibility patch that makes the frontend code a lot easier + req_items = list(self.request.POST.items()) + if '_voucher_item' in self.request.POST and '_voucher_code' in self.request.POST: + req_items.append(( + '%s_voucher' % self.request.POST['_voucher_item'], self.request.POST['_voucher_code'] + )) + pass + items = [] - for key, value in self.request.POST.items(): + for key, value in req_items: if value.strip() == '' or '_' not in key: continue - price = self.request.POST.get('price_' + key.split("_", 1)[1], "") + parts = key.split("_") + if parts[-1] == "voucher": + voucher = value + value = 1 + parts = parts[:-1] + else: + voucher = None + price = self.request.POST.get('price_' + "_".join(parts[1:]), "") if key.startswith('item_'): try: - items.append((int(key.split("_")[1]), None, int(value), price)) + items.append({ + 'item': int(parts[1]), + 'variation': None, + 'count': int(value), + 'price': price, + 'voucher': voucher + }) except ValueError: messages.error(self.request, _('Please enter numbers only.')) return [] elif key.startswith('variation_'): try: - items.append((int(key.split("_")[1]), int(key.split("_")[2]), int(value), price)) + items.append({ + 'item': int(parts[1]), + 'variation': int(parts[2]), + 'count': int(value), + 'price': price, + 'voucher': voucher + }) except ValueError: messages.error(self.request, _('Please enter numbers only.')) return [] - if len(items) == 0 and warn: + if len(items) == 0: messages.warning(self.request, _('You did not select any products.')) return [] return items @@ -95,11 +127,9 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): return super().get_error_message(exception) def post(self, request, *args, **kwargs): - voucher = self.request.POST.get('voucher') - items = self._items_from_post_data(warn=not voucher) - if items or voucher: - return self.do(self.request.event.id, items, self.request.session.session_key, - voucher) + items = self._items_from_post_data() + if items: + return self.do(self.request.event.id, items, self.request.session.session_key) else: if 'ajax' in self.request.GET or 'ajax' in self.request.POST: return JsonResponse({ @@ -107,3 +137,100 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): }) else: return redirect(self.get_error_url()) + + +class RedeemView(EventViewMixin, TemplateView): + template_name = "pretixpresale/event/voucher.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + + context['voucher'] = self.voucher + + # Fetch all items + items = self.request.event.items.all().filter( + Q(active=True) + & Q(Q(available_from__isnull=True) | Q(available_from__lte=now())) + & Q(Q(available_until__isnull=True) | Q(available_until__gte=now())) + ) + + if self.voucher.item_id: + items = items.filter(pk=self.voucher.item_id) + elif self.voucher.quota_id: + items = items.filter(quotas__in=[self.voucher.quota_id]) + + items = items.select_related( + 'category', # for re-grouping + ).prefetch_related( + 'quotas', 'variations__quotas', 'quotas__event' # for .availability() + ).annotate(quotac=Count('quotas')).filter( + quotac__gt=0 + ).distinct().order_by('category__position', 'category_id', 'position', 'name') + + for item in items: + item.available_variations = list(item.variations.filter(active=True, quotas__isnull=False).distinct()) + if self.voucher.item_id and self.voucher.variation_id: + item.available_variations = [v for v in item.available_variations if v.pk == self.voucher.variation_id] + + item.has_variations = item.variations.exists() + if not item.has_variations: + if self.voucher.allow_ignore_quota or self.voucher.block_quota: + item.cached_availability = (Quota.AVAILABILITY_OK, 1) + else: + item.cached_availability = item.check_quotas() + if self.voucher.price is not None: + item.price = self.voucher.price + else: + item.price = item.default_price + else: + for var in item.available_variations: + if self.voucher.allow_ignore_quota or self.voucher.block_quota: + var.cached_availability = (Quota.AVAILABILITY_OK, 1) + else: + var.cached_availability = list(var.check_quotas()) + if self.voucher.price is not None: + var.price = self.voucher.price + else: + var.price = var.default_price if var.default_price is not None else item.default_price + + if len(item.available_variations) > 0: + item.min_price = min([v.price for v in item.available_variations]) + item.max_price = max([v.price for v in item.available_variations]) + + items = [item for item in items if len(item.available_variations) > 0 or not item.has_variations] + context['options'] = sum([(len(item.available_variations) if item.has_variations else 1) + for item in items]) + + # Regroup those by category + context['items_by_category'] = item_group_by_category(items) + + return context + + def dispatch(self, request, *args, **kwargs): + from pretix.base.services.cart import error_messages + + err = None + v = request.GET.get('voucher') + + if v: + try: + self.voucher = Voucher.objects.get(code=v, event=request.event) + if self.voucher.redeemed: + err = error_messages['voucher_redeemed'] + if self.voucher.valid_until is not None and self.voucher.valid_until < now(): + err = error_messages['voucher_expired'] + except Voucher.DoesNotExist: + err = error_messages['voucher_invalid'] + else: + return redirect(eventreverse(request.event, 'presale:event.index')) + + if request.event.presale_start and now() < request.event.presale_start: + err = error_messages['not_started'] + if request.event.presale_end and now() > request.event.presale_end: + err = error_messages['ended'] + + if err: + messages.error(request, err) + return redirect(eventreverse(request.event, 'presale:event.index')) + + return super().dispatch(request, *args, **kwargs) diff --git a/src/pretix/presale/views/event.py b/src/pretix/presale/views/event.py index 3ff57e5a42..eb6de7fd5d 100644 --- a/src/pretix/presale/views/event.py +++ b/src/pretix/presale/views/event.py @@ -4,7 +4,21 @@ from django.db.models import Count, Q from django.utils.timezone import now from django.views.generic import TemplateView -from pretix.presale.views import CartMixin, EventViewMixin +from . import CartMixin, EventViewMixin + + +def item_group_by_category(items): + return sorted( + [ + # a group is a tuple of a category and a list of items + (cat, [i for i in items if i.category == cat]) + for cat in set([i.category for i in items]) + # insert categories into a set for uniqueness + # a set is unsorted, so sort again by category + ], + key=lambda group: (group[0].position, group[0].id) if ( + group[0] is not None and group[0].id is not None) else (0, 0) + ) class EventIndex(EventViewMixin, CartMixin, TemplateView): @@ -48,17 +62,7 @@ class EventIndex(EventViewMixin, CartMixin, TemplateView): items = [item for item in items if len(item.available_variations) > 0 or not item.has_variations] # Regroup those by category - context['items_by_category'] = sorted( - [ - # a group is a tuple of a category and a list of items - (cat, [i for i in items if i.category == cat]) - for cat in set([i.category for i in items]) - # insert categories into a set for uniqueness - # a set is unsorted, so sort again by category - ], - key=lambda group: (group[0].position, group[0].id) if ( - group[0] is not None and group[0].id is not None) else (0, 0) - ) + context['items_by_category'] = item_group_by_category(items) vouchers_exist = self.request.event.get_cache().get('vouchers_exist') if vouchers_exist is None: diff --git a/src/static/pretixpresale/scss/_event.scss b/src/static/pretixpresale/scss/_event.scss index c1d51fdbbb..6a28f762db 100644 --- a/src/static/pretixpresale/scss/_event.scss +++ b/src/static/pretixpresale/scss/_event.scss @@ -33,6 +33,16 @@ text-decoration: none; display: block; } + + .radio-box { + text-align: center; + label { + display: block; + width: 100%; + line-height: 19px; + margin: 0; + } + } } .voucher-row { margin-top: 10px; diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index dc8ceeae9d..70d28cfad7 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -7,7 +7,7 @@ from django.utils.timezone import now from pretix.base.models import ( CachedFile, CartPosition, Event, Item, ItemCategory, ItemVariation, Order, - OrderPosition, Organizer, Question, Quota, User, + OrderPosition, Organizer, Question, Quota, User, Voucher, ) from pretix.base.services.orders import mark_order_paid @@ -174,6 +174,42 @@ class QuotaTestCase(BaseQuotaTestCase): self.quota.save() self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, None)) + def test_voucher_product(self): + self.quota.items.add(self.item1) + self.quota.size = 1 + self.quota.save() + + v = Voucher.objects.create(item=self.item1, event=self.event) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) + + v.block_quota = True + v.save() + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0)) + + def test_voucher_variation(self): + self.quota.variations.add(self.var1) + self.quota.size = 1 + self.quota.save() + + v = Voucher.objects.create(item=self.item2, variation=self.var1, event=self.event) + self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) + + v.block_quota = True + v.save() + self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0)) + + def test_voucher_quota(self): + self.quota.variations.add(self.var1) + self.quota.size = 1 + self.quota.save() + + v = Voucher.objects.create(quota=self.quota, event=self.event) + self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) + + v.block_quota = True + v.save() + self.assertEqual(self.var1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0)) + class OrderTestCase(BaseQuotaTestCase): def setUp(self): diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index 81a7a4368e..48ba325232 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -442,7 +442,7 @@ class CartTest(CartTestMixin, TestCase): def test_voucher(self): v = Voucher.objects.create(item=self.ticket, event=self.event) self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': v.code + 'item_%d_voucher' % self.ticket.id: v.code, }, follow=True) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 1) @@ -453,17 +453,35 @@ class CartTest(CartTestMixin, TestCase): def test_voucher_variation(self): v = Voucher.objects.create(item=self.shirt, variation=self.shirt_red, event=self.event) self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': v.code + 'variation_%d_%d_voucher' % (self.shirt.id, self.shirt_red.id): v.code, }, follow=True) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 1) self.assertEqual(objs[0].item, self.shirt) self.assertEqual(objs[0].variation, self.shirt_red) + def test_voucher_quota(self): + v = Voucher.objects.create(quota=self.quota_shirts, event=self.event) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d_voucher' % (self.shirt.id, self.shirt_red.id): v.code, + }, follow=True) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.shirt) + self.assertEqual(objs[0].variation, self.shirt_red) + + def test_voucher_quota_invalid_item(self): + v = Voucher.objects.create(quota=self.quota_tickets, event=self.event) + self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%d_%d_voucher' % (self.shirt.id, self.shirt_red.id): v.code, + }, follow=True) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 0) + def test_voucher_price(self): v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event) self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': v.code + 'item_%d_voucher' % self.ticket.id: v.code, }, follow=True) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 1) @@ -474,7 +492,7 @@ class CartTest(CartTestMixin, TestCase): def test_voucher_redemed(self): v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, redeemed=True) response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': v.code + 'item_%d_voucher' % self.ticket.id: v.code, }, follow=True) doc = BeautifulSoup(response.rendered_content) self.assertIn('already been used', doc.select('.alert-danger')[0].text) @@ -484,7 +502,7 @@ class CartTest(CartTestMixin, TestCase): v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, valid_until=now() - timedelta(days=2)) response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': v.code + 'item_%d_voucher' % self.ticket.id: v.code, }, follow=True) doc = BeautifulSoup(response.rendered_content) self.assertIn('expired', doc.select('.alert-danger')[0].text) @@ -492,7 +510,7 @@ class CartTest(CartTestMixin, TestCase): def test_voucher_invalid(self): response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': 'ABC' + 'item_%d_voucher' % self.ticket.id: 'ABC', }, follow=True) doc = BeautifulSoup(response.rendered_content) self.assertIn('not known', doc.select('.alert-danger')[0].text) @@ -503,7 +521,7 @@ class CartTest(CartTestMixin, TestCase): self.quota_tickets.save() v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event) response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': v.code + 'item_%d_voucher' % self.ticket.id: v.code, }, follow=True) doc = BeautifulSoup(response.rendered_content) self.assertIn('no longer available', doc.select('.alert-danger')[0].text) @@ -515,7 +533,7 @@ class CartTest(CartTestMixin, TestCase): v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, allow_ignore_quota=True) self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { - 'voucher': v.code + 'item_%d_voucher' % self.ticket.id: v.code, }, follow=True) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 1) @@ -535,10 +553,28 @@ class CartTest(CartTestMixin, TestCase): self.assertIn('no longer available', doc.select('.alert-danger')[0].text) 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), { - 'voucher': v.code + 'item_%d_voucher' % self.ticket.id: v.code, }, follow=True) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) self.assertEqual(len(objs), 1) self.assertEqual(objs[0].item, self.ticket) self.assertIsNone(objs[0].variation) self.assertEqual(objs[0].price, Decimal('12.00')) + + def test_voucher_doubled(self): + v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event) + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d_voucher' % self.ticket.id: v.code, + }, follow=True) + objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) + self.assertEqual(len(objs), 1) + self.assertEqual(objs[0].item, self.ticket) + self.assertIsNone(objs[0].variation) + self.assertEqual(objs[0].price, Decimal('12.00')) + + response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_%d_voucher' % self.ticket.id: v.code, + }, follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertIn('already been used', doc.select('.alert-danger')[0].text) + self.assertEqual(1, CartPosition.objects.filter(cart_id=self.session_key, event=self.event).count()) diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 7ab1928812..7ccbc2a82d 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -309,6 +309,7 @@ class CheckoutTestCase(TestCase): self.assertEqual(Order.objects.count(), 1) self.assertEqual(OrderPosition.objects.count(), 1) self.assertEqual(OrderPosition.objects.first().voucher, v) + self.assertTrue(Voucher.objects.get(pk=v.pk).redeemed) def test_voucher_price_changed(self): v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, @@ -392,6 +393,34 @@ class CheckoutTestCase(TestCase): self.assertEqual(Order.objects.count(), 1) self.assertEqual(OrderPosition.objects.count(), 1) + def test_voucher_double(self): + self.quota_tickets.size = 2 + self.quota_tickets.save() + v = Voucher.objects.create(item=self.ticket, event=self.event, + valid_until=now() + timedelta(days=2), block_quota=True) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, expires=now() + timedelta(minutes=10), voucher=v + ) + CartPosition.objects.create( + event=self.event, cart_id=self.session_key, item=self.ticket, + price=23, 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.rendered_content) + self.assertEqual(CartPosition.objects.filter(cart_id=self.session_key, voucher=v).count(), 1) + self.assertEqual(len(doc.select(".alert-danger")), 1) + self.assertFalse(Order.objects.exists()) + + response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True) + doc = BeautifulSoup(response.rendered_content) + self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, voucher=v).exists()) + self.assertEqual(len(doc.select(".thank-you")), 1) + self.assertEqual(Order.objects.count(), 1) + self.assertEqual(OrderPosition.objects.count(), 1) + def test_confirm_expired_partial(self): self.quota_tickets.size = 1 self.quota_tickets.save()