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:
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])
)

View File

@@ -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)

View File

@@ -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,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
# 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]
# 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[2]:
# This quota is not available or less than i[2] items are left, so we have to
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']
@@ -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']
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 (
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):
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:

View File

@@ -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:

View File

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

View File

@@ -27,7 +27,15 @@
</td>
<td>{% if v.redeemed %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</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">
<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>

View File

@@ -189,27 +189,6 @@
</section>
{% endfor %}
{% 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="col-md-4 col-md-offset-8 col-xs-12">
<button class="btn btn-block btn-primary btn-lg" type="submit">
@@ -221,4 +200,24 @@
{% endif %}
</form>
{% 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 %}

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/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<step>[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(),
name='event.checkout'),
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.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)

View File

@@ -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:

View File

@@ -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;

View File

@@ -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):

View File

@@ -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())

View File

@@ -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()