Allow for vouchers that are valid for multiple items

This commit is contained in:
Raphael Michel
2016-05-04 17:50:19 +02:00
parent bda0075613
commit 09cee356b0
18 changed files with 669 additions and 154 deletions

View File

@@ -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'),
),
]

View File

@@ -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'),
),
]

View File

@@ -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 = [
]

View File

@@ -513,10 +513,9 @@ class Quota(LoggedModel):
def count_blocking_vouchers(self) -> int: def count_blocking_vouchers(self) -> int:
from pretix.base.models import Voucher from pretix.base.models import Voucher
return Voucher.objects.filter( return Voucher.objects.filter(
Q(item__quotas__in=[self]) &
Q(block_quota=True) & Q(block_quota=True) &
Q(redeemed=False) & Q(redeemed=False) &
self._position_lookup Q(Q(self._position_lookup) | Q(quota=self))
).distinct().count() ).distinct().count()
def count_in_cart(self) -> int: def count_in_cart(self) -> int:
@@ -546,8 +545,8 @@ class Quota(LoggedModel):
def _position_lookup(self) -> Q: def _position_lookup(self) -> Q:
return ( return (
( # Orders for items which do not have any variations ( # Orders for items which do not have any variations
Q(variation__isnull=True) Q(variation__isnull=True) &
& Q(item__quotas__in=[self]) Q(item__quotas__in=[self])
) | ( # Orders for items which do have any variations ) | ( # Orders for items which do have any variations
Q(variation__quotas__in=[self]) Q(variation__quotas__in=[self])
) )

View File

@@ -1,11 +1,13 @@
import random import random
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from .base import LoggedModel from .base import LoggedModel
from .event import Event from .event import Event
from .items import Item, ItemVariation from .items import Item, ItemVariation, Quota
from .orders import CartPosition, OrderPosition from .orders import CartPosition, OrderPosition
@@ -59,6 +61,7 @@ class Voucher(LoggedModel):
item = models.ForeignKey( item = models.ForeignKey(
Item, related_name='vouchers', Item, related_name='vouchers',
verbose_name=_("Product"), verbose_name=_("Product"),
null=True, blank=True,
help_text=_( help_text=_(
"This product is added to the user's cart if the voucher is redeemed." "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." "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: class Meta:
verbose_name = _("Voucher") verbose_name = _("Voucher")
@@ -80,6 +91,21 @@ class Voucher(LoggedModel):
def __str__(self): def __str__(self):
return self.code 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): def save(self, *args, **kwargs):
self.code = self.code.upper() self.code = self.code.upper()
super().save(*args, **kwargs) super().save(*args, **kwargs)

View File

@@ -31,7 +31,8 @@ error_messages = {
'ended': _('The presale period has ended.'), 'ended': _('The presale period has ended.'),
'voucher_invalid': _('This voucher code is not known in our database.'), '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_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) ).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() positions = set()
# For items that are already expired, we have to delete and re-add them, as they might # 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! # 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()) Q(cart_id=cart_id) & Q(event=event) & Q(expires__lte=now())
) )
for cp in expired: 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) positions.add(cp)
return positions return positions
@@ -70,17 +78,17 @@ def _check_date(event: Event) -> None:
raise CartError(error_messages['ended']) 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]: cart_id: str, expiry: datetime) -> Optional[str]:
err = None err = None
# Fetch items from the database # 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") "quotas")
items_cache = {i.id: i for i in items_query} items_cache = {i.id: i for i in items_query}
variations_query = ItemVariation.objects.filter( variations_query = ItemVariation.objects.filter(
item__event=event, 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") ).select_related("item", "item__event").prefetch_related("quotas")
variations_cache = {v.id: v for v in variations_query} variations_cache = {v.id: v for v in variations_query}
@@ -88,26 +96,51 @@ 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 # 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 # If they are not, the user supplied item IDs which either do not exist or belong to
# a different event # 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'] err = err or error_messages['not_for_sale']
continue continue
item = items_cache[i[0]] item = items_cache[i['item']]
variation = variations_cache[i[1]] if i[1] is not None else None 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. # 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()) 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(): if len(quotas) == 0 or not item.is_available():
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
continue continue
# Check that all quotas allow us to buy i[2] instances of the object # Check that all quotas allow us to buy i['count'] instances of the object
quota_ok = i[2] quota_ok = i['count']
if not voucher or (not voucher.allow_ignore_quota and not voucher.block_quota):
for quota in quotas: for quota in quotas:
avail = quota.availability() avail = quota.availability()
if avail[1] is not None and avail[1] < i[2]: if avail[1] is not None and avail[1] < i['count']:
# This quota is not available or less than i[2] items are left, so we have to # This quota is not available or less than i['count'] items are left, so we have to
# reduce the number of bought items # reduce the number of bought items
if avail[0] != Quota.AVAILABILITY_OK: if avail[0] != Quota.AVAILABILITY_OK:
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
@@ -115,19 +148,23 @@ def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int, Opti
err = err or error_messages['in_part'] err = err or error_messages['in_part']
quota_ok = min(quota_ok, avail[1]) quota_ok = min(quota_ok, avail[1])
if voucher and voucher.price is not None:
price = voucher.price
else:
price = item.default_price if variation is None else ( price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price) 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 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): if not isinstance(custom_price, Decimal):
custom_price = Decimal(custom_price.replace(",", ".")) custom_price = Decimal(custom_price.replace(",", "."))
price = max(custom_price, price) price = max(custom_price, price)
# Create a CartPosition for as much items as we can # Create a CartPosition for as much items as we can
for k in range(quota_ok): for k in range(quota_ok):
if len(i) > 4 and i[2] == 1: if 'cp' in i and i['count'] == 1:
# Recreating # Recreating
cp = i[4] cp = i['cp']
cp.expires = expiry cp.expires = expiry
cp.price = price cp.price = price
cp.save() 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, event=event, item=item, variation=variation,
price=price, price=price,
expires=expiry, expires=expiry,
cart_id=cart_id cart_id=cart_id, voucher=voucher
) )
return err return err
def _add_voucher(event: Event, voucher: str, expiry: datetime, cart_id: str): def _add_items_to_cart(event: Event, items: List[dict], cart_id: str=None) -> None:
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:
with event.lock(): with event.lock():
_check_date(event) _check_date(event)
existing = CartPosition.objects.filter(Q(cart_id=cart_id) & Q(event=event)).count() 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 # TODO: i18n plurals
raise CartError(error_messages['max_items'], (event.settings.max_items_per_order,)) 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) _delete_expired(expired)
if err: if err:
raise CartError(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, def add_items_to_cart(event: int, items: List[dict], cart_id: str=None) -> None:
voucher: str=None) -> None:
""" """
Adds a list of items to a user's cart. Adds a list of items to a user's cart.
:param event: The event ID in question :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 session: Session ID of a guest
:param coupon: A coupon that should also be reeemed :param coupon: A coupon that should also be reeemed
:raises CartError: On any error that occured :raises CartError: On any error that occured
""" """
event = Event.objects.get(id=event) 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: except EventLock.LockTimeoutException:
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
def _remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]], def _remove_items_from_cart(event: Event, items: List[dict], cart_id: str) -> None:
cart_id: str) -> None:
with event.lock(): with event.lock():
for item, variation, cnt, price in items: for i in items:
cw = Q(cart_id=cart_id) & Q(item_id=item) & Q(event=event) cw = Q(cart_id=cart_id) & Q(item_id=i['item']) & Q(event=event)
if variation: if i['variation']:
cw &= Q(variation_id=variation) cw &= Q(variation_id=i['variation'])
else: else:
cw &= Q(variation__isnull=True) cw &= Q(variation__isnull=True)
# Prefer to delete positions that have the same price as the one the user clicked on, after thet # Prefer to delete positions that have the same price as the one the user clicked on, after thet
# prefer the most expensive ones. # prefer the most expensive ones.
if price: cnt = i['count']
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(price.replace(",", ".")))[:cnt] if i['price']:
correctprice = CartPosition.objects.filter(cw).filter(price=Decimal(i['price'].replace(",", ".")))[:cnt]
for cp in correctprice: for cp in correctprice:
cp.delete() cp.delete()
cnt -= len(correctprice) cnt -= len(correctprice)
@@ -231,8 +234,7 @@ def _remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], in
cp.delete() cp.delete()
def remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]], def remove_items_from_cart(event: int, items: List[dict], cart_id: str=None) -> None:
cart_id: str=None) -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
@@ -250,20 +252,18 @@ if settings.HAS_CELERY:
from pretix.celery import app from pretix.celery import app
@app.task(bind=True, max_retries=5, default_retry_delay=1) @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]]], def add_items_to_cart_task(self, event: int, items: List[dict], cart_id: str):
cart_id: str, voucher: str=None):
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
try: try:
try: try:
_add_items_to_cart(event, items, cart_id, voucher) _add_items_to_cart(event, items, cart_id)
except EventLock.LockTimeoutException: except EventLock.LockTimeoutException:
self.retry(exc=CartError(error_messages['busy'])) self.retry(exc=CartError(error_messages['busy']))
except CartError as e: except CartError as e:
return e return e
@app.task(bind=True, max_retries=5, default_retry_delay=1) @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]], def remove_items_from_cart_task(self, event: int, items: List[dict], cart_id: str):
cart_id: str):
event = Event.objects.get(id=event) event = Event.objects.get(id=event)
try: try:
try: try:

View File

@@ -158,6 +158,7 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]):
err = None err = None
_check_date(event) _check_date(event)
voucherids = set()
for i, cp in enumerate(positions): for i, cp in enumerate(positions):
if not cp.item.active: if not cp.item.active:
err = err or error_messages['unavailable'] 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()) quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
if cp.voucher: if cp.voucher:
if cp.voucher.redeemed: if cp.voucher.redeemed or cp.voucher_id in voucherids:
err = err or error_messages['voucher_redeemed'] err = err or error_messages['voucher_redeemed']
cp.delete() # Sorry! But you should have never gotten into this state at all.
continue continue
voucherids.add(cp.voucher_id)
if cp.expires >= dt: if cp.expires >= dt and not cp.voucher:
# Other checks are not necessary # Other checks are not necessary
continue continue
@@ -183,7 +186,7 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]):
continue continue
if cp.voucher: 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'] err = err or error_messages['voucher_expired']
continue continue
if cp.voucher.price is not None: if cp.voucher.price is not None:

View File

@@ -2,7 +2,7 @@ from django import forms
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from pretix.base.forms import I18nModelForm 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): class VoucherForm(I18nModelForm):
@@ -29,6 +29,8 @@ class VoucherForm(I18nModelForm):
initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk) initial['itemvar'] = '%d-%d' % (instance.item.pk, instance.variation.pk)
elif instance.item: elif instance.item:
initial['itemvar'] = str(instance.item.pk) initial['itemvar'] = str(instance.item.pk)
elif instance.quota:
initial['itemvar'] = 'q-%d' % instance.quota.pk
except Item.DoesNotExist: except Item.DoesNotExist:
pass pass
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -36,22 +38,39 @@ class VoucherForm(I18nModelForm):
for i in self.instance.event.items.prefetch_related('variations').all(): for i in self.instance.event.items.prefetch_related('variations').all():
variations = list(i.variations.all()) variations = list(i.variations.all())
if variations: if variations:
choices.append((str(i.pk), _('{product} Any variation').format(product=i.name)))
for v in variations: for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value))) choices.append(('%d-%d' % (i.pk, v.pk), '%s %s' % (i.name, v.value)))
else: else:
choices.append((str(i.pk), i.name)) 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 self.fields['itemvar'].choices = choices
def save(self, commit=True): def clean(self):
if '-' in self.cleaned_data['itemvar']: data = super().clean()
itemid, varid = self.cleaned_data['itemvar'].split('-') 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: else:
itemid, varid = self.cleaned_data['itemvar'], None itemid, varid = self.data['itemvar'], None
if itemid:
self.instance.item = Item.objects.get(pk=itemid, event=self.instance.event) self.instance.item = Item.objects.get(pk=itemid, event=self.instance.event)
if varid: if varid:
self.instance.variation = ItemVariation.objects.get(pk=varid, item=self.instance.item) self.instance.variation = ItemVariation.objects.get(pk=varid, item=self.instance.item)
else: else:
self.instance.variation = None 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) super().save(commit)
return ['item'] return ['item']

View File

@@ -27,7 +27,15 @@
</td> </td>
<td>{% if v.redeemed %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td> <td>{% if v.redeemed %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
<td>{{ v.valid_until|date }}</td> <td>{{ v.valid_until|date }}</td>
<td>{{ v.item }}</td> <td>
{% if v.item %}
{{ v.item }}
{% else %}
{% blocktrans trimmed with quota=v.quota.name %}
Any product in quota "{{ quota }}"
{% endblocktrans %}
{% endif %}
</td>
<td class="text-right"> <td class="text-right">
<a href="{% url "control:event.voucher.delete" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> <a href="{% url "control:event.voucher.delete" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td> </td>

View File

@@ -189,27 +189,6 @@
</section> </section>
{% endfor %} {% endfor %}
{% if event.presale_is_running %} {% if event.presale_is_running %}
{% if vouchers_exist %}
<div class="row-fluid voucher-row">
<div class="col-md-4 col-md-offset-8 col-xs-12">
<div id="voucher-box">
<label for="voucher">{% trans "Redeem a voucher" %}</label>
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-ticket fa-fw"></i></span>
<input type="text" class="form-control" name="voucher" id="voucher"
placeholder="{% trans "Voucher code" %}">
</div>
</div>
<div id="voucher-toggle">
<a href="javascript:void(0);">
<span class="fa fa-ticket"></span> {% trans "Redeem a voucher" %}
</a>
</div>
</div>
<div class="clearfix"></div>
</div>
{% endif %}
<div class="row-fluid checkout-button-row"> <div class="row-fluid checkout-button-row">
<div class="col-md-4 col-md-offset-8 col-xs-12"> <div class="col-md-4 col-md-offset-8 col-xs-12">
<button class="btn btn-block btn-primary btn-lg" type="submit"> <button class="btn btn-block btn-primary btn-lg" type="submit">
@@ -221,4 +200,24 @@
{% endif %} {% endif %}
</form> </form>
{% endif %} {% endif %}
{% if vouchers_exist %}
<h2>{% trans "Redeem a voucher" %}</h2>
<form method="get" action="{% eventurl event "presale:event.redeem" %}">
<div class="row-fluid">
<div class="col-md-8 col-sm-6 col-xs-12">
<div class="input-group">
<span class="input-group-addon"><i class="fa fa-ticket fa-fw"></i></span>
<input type="text" class="form-control" name="voucher" id="voucher"
placeholder="{% trans "Voucher code" %}">
</div>
</div>
<div class="col-md-4 col-sm-6 col-xs-12">
<button class="btn btn-block btn-primary" type="submit">
{% trans "Redeem voucher" %}
</button>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endif %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,154 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% load eventurl %}
{% load thumbnail %}
{% block title %}{% trans "Voucher redemption" %}{% endblock %}
{% block content %}
<h2>{% trans "Voucher redemption" %}</h2>
<p>
{% blocktrans trimmed %}
You entered a voucher code that allows you to buy one of the following products at the specified price:
{% endblocktrans %}
</p>
{% if event.presale_is_running or event.settings.show_items_outside_presale_period %}
<form method="post" data-asynctask
action="{% eventurl request.event "presale:event.cart.add" %}?next={{ request.path|urlencode }}">
{% csrf_token %}
<input type="hidden" name="_voucher_code" value="{{ voucher.code }}">
{% for tup in items_by_category %}
<section>
{% if tup.0 %}<h3>{{ tup.0.name }}</h3>{% endif %}
{% for item in tup.1 %}
{% if item.has_variations %}
<div class="item-with-variations">
<div class="row-fluid product-row headline">
<div class="col-md-8 col-xs-12">
{% if item.picture %}
<a href="{{ item.picture.url }}" class="productpicture"
data-title="{{ item.name }}"
data-lightbox="{{ item.id }}">
<img src="{{ item.picture|thumbnail_url:'productlist' }}"
alt="{{ item.name }}"/>
</a>
{% endif %}
<strong>{{ item.name }}</strong>
{% if item.description %}<p>{{ item.description }}</p>{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% 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 %}
</div>
<div class="col-md-2 col-xs-6 availability-box">
</div>
<div class="clearfix"></div>
</div>
<div class="">
{% for var in item.available_variations %}
<div class="row-fluid product-row variation">
<div class="col-md-8 col-xs-12">
{{ var }}
</div>
<div class="col-md-2 col-xs-6 price">
{% if item.free_price %}
<div class="input-group input-group-price">
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price"
placeholder="0"
min="{{ var.price|stringformat:"0.2f" }}"
name="price_{{ item.id }}_{{ var.id }}"
step="0.01" value="{{ var.price|stringformat:"0.2f" }}">
</div>
{% else %}
{{ event.currency }} {{ var.price|floatformat:2 }}
{% endif %}
{% if item.tax_rate %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
</div>
{% if var.cached_availability.0 == 100 %}
<div class="col-md-2 col-xs-6 availability-box available radio-box">
<label>
<input type="radio" name="_voucher_item"
{% if options == 1 %}checked="checked"{% endif %}
value="variation_{{ item.id }}_{{ var.id }}">
</label>
</div>
{% else %}
{% include "pretixpresale/event/fragment_availability.html" with avail=var.cached_availability.0 %}
{% endif %}
<div class="clearfix"></div>
</div>
{% endfor %}
</div>
</div>
{% else %}
<div class="row-fluid product-row simple">
<div class="col-md-8 col-xs-12">
{% if item.picture %}
<a href="{{ item.picture.url }}" class="productpicture"
data-title="{{ item.name }}"
data-lightbox="{{ item.id }}">
<img src="{{ item.picture|thumbnail_url:'productlist' }}"
alt="{{ item.name }}"/>
</a>
{% endif %}
<strong>{{ item.name }}</strong>
{% if item.description %}
<p class="description">{{ item.description }}</p>{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{% if item.free_price %}
<div class="input-group input-group-price">
<span class="input-group-addon">{{ event.currency }}</span>
<input type="number" class="form-control input-item-price" placeholder="0"
min="{{ item.price|stringformat:"0.2f" }}"
name="price_{{ item.id }}"
step="0.01" value="{{ item.price|stringformat:"0.2f" }}">
</div>
{% else %}
{{ event.currency }} {{ item.price|floatformat:2 }}
{% endif %}
{% if item.tax_rate %}
<small>{% blocktrans trimmed with rate=item.tax_rate %}
incl. {{ rate }}% taxes
{% endblocktrans %}</small>
{% endif %}
</div>
{% if item.cached_availability.0 == 100 %}
<div class="col-md-2 col-xs-6 availability-box available radio-box">
<label>
<input type="radio" name="_voucher_item"
{% if options == 1 %}checked="checked"{% endif %}
value="item_{{ item.id }}">
</label>
</div>
{% else %}
{% include "pretixpresale/event/fragment_availability.html" with avail=item.cached_availability.0 %}
{% endif %}
<div class="clearfix"></div>
</div>
{% endif %}
{% endfor %}
</section>
{% endfor %}
{% if event.presale_is_running %}
<div class="row-fluid checkout-button-row">
<div class="col-md-4 col-md-offset-8 col-xs-12">
<button class="btn btn-block btn-primary btn-lg" type="submit">
<i class="fa fa-shopping-cart"></i> {% trans "Add to cart" %}
</button>
</div>
<div class="clearfix"></div>
</div>
{% endif %}
</form>
{% endif %}
{% endblock %}

View File

@@ -14,6 +14,8 @@ event_patterns = [
url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'), 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'^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'^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<step>[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(), url(r'^checkout/(?P<step>[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(),
name='event.checkout'), name='event.checkout'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/$', pretix.presale.views.order.OrderDetails.as_view(), url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/$', pretix.presale.views.order.OrderDetails.as_view(),

View File

@@ -1,15 +1,19 @@
from django.contrib import messages from django.contrib import messages
from django.db.models import Count, Q
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.timezone import now
from django.utils.translation import ugettext as _ 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 ( from pretix.base.services.cart import (
CartError, add_items_to_cart, remove_items_from_cart, CartError, add_items_to_cart, remove_items_from_cart,
) )
from pretix.multidomain.urlreverse import eventreverse from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.views import EventViewMixin from pretix.presale.views import EventViewMixin
from pretix.presale.views.async import AsyncAction from pretix.presale.views.async import AsyncAction
from pretix.presale.views.event import item_group_by_category
class CartActionMixin: class CartActionMixin:
@@ -26,30 +30,58 @@ class CartActionMixin:
def get_error_url(self): def get_error_url(self):
return self.get_next_url() 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 Parses the POST data and returns a list of tuples in the
form (item id, variation id or None, number) 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 = [] items = []
for key, value in self.request.POST.items(): for key, value in req_items:
if value.strip() == '' or '_' not in key: if value.strip() == '' or '_' not in key:
continue 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_'): if key.startswith('item_'):
try: 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: except ValueError:
messages.error(self.request, _('Please enter numbers only.')) messages.error(self.request, _('Please enter numbers only.'))
return [] return []
elif key.startswith('variation_'): elif key.startswith('variation_'):
try: 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: except ValueError:
messages.error(self.request, _('Please enter numbers only.')) messages.error(self.request, _('Please enter numbers only.'))
return [] return []
if len(items) == 0 and warn: if len(items) == 0:
messages.warning(self.request, _('You did not select any products.')) messages.warning(self.request, _('You did not select any products.'))
return [] return []
return items return items
@@ -95,11 +127,9 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
return super().get_error_message(exception) return super().get_error_message(exception)
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
voucher = self.request.POST.get('voucher') items = self._items_from_post_data()
items = self._items_from_post_data(warn=not voucher) if items:
if items or voucher: return self.do(self.request.event.id, items, self.request.session.session_key)
return self.do(self.request.event.id, items, self.request.session.session_key,
voucher)
else: else:
if 'ajax' in self.request.GET or 'ajax' in self.request.POST: if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
return JsonResponse({ return JsonResponse({
@@ -107,3 +137,100 @@ class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View):
}) })
else: else:
return redirect(self.get_error_url()) 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)

View File

@@ -4,7 +4,21 @@ from django.db.models import Count, Q
from django.utils.timezone import now from django.utils.timezone import now
from django.views.generic import TemplateView 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): 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] items = [item for item in items if len(item.available_variations) > 0 or not item.has_variations]
# Regroup those by category # Regroup those by category
context['items_by_category'] = sorted( context['items_by_category'] = item_group_by_category(items)
[
# 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)
)
vouchers_exist = self.request.event.get_cache().get('vouchers_exist') vouchers_exist = self.request.event.get_cache().get('vouchers_exist')
if vouchers_exist is None: if vouchers_exist is None:

View File

@@ -33,6 +33,16 @@
text-decoration: none; text-decoration: none;
display: block; display: block;
} }
.radio-box {
text-align: center;
label {
display: block;
width: 100%;
line-height: 19px;
margin: 0;
}
}
} }
.voucher-row { .voucher-row {
margin-top: 10px; margin-top: 10px;

View File

@@ -7,7 +7,7 @@ from django.utils.timezone import now
from pretix.base.models import ( from pretix.base.models import (
CachedFile, CartPosition, Event, Item, ItemCategory, ItemVariation, Order, 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 from pretix.base.services.orders import mark_order_paid
@@ -174,6 +174,42 @@ class QuotaTestCase(BaseQuotaTestCase):
self.quota.save() self.quota.save()
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, None)) 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): class OrderTestCase(BaseQuotaTestCase):
def setUp(self): def setUp(self):

View File

@@ -442,7 +442,7 @@ class CartTest(CartTestMixin, TestCase):
def test_voucher(self): def test_voucher(self):
v = Voucher.objects.create(item=self.ticket, event=self.event) v = Voucher.objects.create(item=self.ticket, event=self.event)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 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) }, follow=True)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1) self.assertEqual(len(objs), 1)
@@ -453,17 +453,35 @@ class CartTest(CartTestMixin, TestCase):
def test_voucher_variation(self): def test_voucher_variation(self):
v = Voucher.objects.create(item=self.shirt, variation=self.shirt_red, event=self.event) 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), { 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) }, follow=True)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1) self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.shirt) self.assertEqual(objs[0].item, self.shirt)
self.assertEqual(objs[0].variation, self.shirt_red) 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): def test_voucher_price(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event) 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), { 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) }, follow=True)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1) self.assertEqual(len(objs), 1)
@@ -474,7 +492,7 @@ class CartTest(CartTestMixin, TestCase):
def test_voucher_redemed(self): def test_voucher_redemed(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, redeemed=True) 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), { 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) }, follow=True)
doc = BeautifulSoup(response.rendered_content) doc = BeautifulSoup(response.rendered_content)
self.assertIn('already been used', doc.select('.alert-danger')[0].text) 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, v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
valid_until=now() - timedelta(days=2)) valid_until=now() - timedelta(days=2))
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 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) }, follow=True)
doc = BeautifulSoup(response.rendered_content) doc = BeautifulSoup(response.rendered_content)
self.assertIn('expired', doc.select('.alert-danger')[0].text) self.assertIn('expired', doc.select('.alert-danger')[0].text)
@@ -492,7 +510,7 @@ class CartTest(CartTestMixin, TestCase):
def test_voucher_invalid(self): def test_voucher_invalid(self):
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 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) }, follow=True)
doc = BeautifulSoup(response.rendered_content) doc = BeautifulSoup(response.rendered_content)
self.assertIn('not known', doc.select('.alert-danger')[0].text) self.assertIn('not known', doc.select('.alert-danger')[0].text)
@@ -503,7 +521,7 @@ class CartTest(CartTestMixin, TestCase):
self.quota_tickets.save() self.quota_tickets.save()
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event) 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), { 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) }, follow=True)
doc = BeautifulSoup(response.rendered_content) doc = BeautifulSoup(response.rendered_content)
self.assertIn('no longer available', doc.select('.alert-danger')[0].text) 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, v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
allow_ignore_quota=True) allow_ignore_quota=True)
self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { 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) }, follow=True)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1) 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.assertIn('no longer available', doc.select('.alert-danger')[0].text)
self.assertFalse(CartPosition.objects.filter(cart_id=self.session_key, event=self.event).exists()) 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), { 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) }, follow=True)
objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event)) objs = list(CartPosition.objects.filter(cart_id=self.session_key, event=self.event))
self.assertEqual(len(objs), 1) self.assertEqual(len(objs), 1)
self.assertEqual(objs[0].item, self.ticket) self.assertEqual(objs[0].item, self.ticket)
self.assertIsNone(objs[0].variation) self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, Decimal('12.00')) 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())

View File

@@ -309,6 +309,7 @@ class CheckoutTestCase(TestCase):
self.assertEqual(Order.objects.count(), 1) self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 1) self.assertEqual(OrderPosition.objects.count(), 1)
self.assertEqual(OrderPosition.objects.first().voucher, v) self.assertEqual(OrderPosition.objects.first().voucher, v)
self.assertTrue(Voucher.objects.get(pk=v.pk).redeemed)
def test_voucher_price_changed(self): def test_voucher_price_changed(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event, 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(Order.objects.count(), 1)
self.assertEqual(OrderPosition.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): def test_confirm_expired_partial(self):
self.quota_tickets.size = 1 self.quota_tickets.size = 1
self.quota_tickets.save() self.quota_tickets.save()