Refs #145 -- Multi-use vouchers

This commit is contained in:
Raphael Michel
2016-11-27 00:02:28 +01:00
parent 6c2ecd153c
commit db6fb51fc6
18 changed files with 470 additions and 104 deletions

View File

@@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.2 on 2016-11-26 13:00
from __future__ import unicode_literals
import django.core.validators
from django.db import migrations, models
import pretix.base.validators
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0046_order_meta_info'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='max_usages',
field=models.PositiveIntegerField(default=1, help_text='Number of times this voucher can be redeemed.', verbose_name='Maximum usages'),
),
migrations.AlterField(
model_name='event',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Slug'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters and numbers, and must be unique among your events. This is being used in addresses and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Slug'),
),
migrations.AlterField(
model_name='voucher',
name='redeemed',
field=models.PositiveIntegerField(default=0, verbose_name='Redeemed'),
),
]

View File

@@ -4,8 +4,9 @@ from datetime import datetime
from decimal import Decimal
from typing import Tuple
from django.conf import settings
from django.db import models
from django.db.models import Q
from django.db.models import F, Func, Q, Sum
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
@@ -577,12 +578,18 @@ class Quota(LoggedModel):
from pretix.base.models import Voucher
now_dt = now_dt or now()
if 'sqlite3' in settings.DATABASES['default']['ENGINE']:
func = 'MAX'
else:
func = 'GREATEST'
return Voucher.objects.filter(
Q(block_quota=True) &
Q(redeemed=False) &
Q(Q(valid_until__isnull=True) | Q(valid_until__gte=now_dt)) &
Q(Q(self._position_lookup) | Q(quota=self))
).values('id').distinct().count()
).values('id').aggregate(
free=Sum(Func(F('max_usages') - F('redeemed'), 0, function=func))
)['free'] or 0
def count_in_cart(self, now_dt: datetime=None) -> int:
from pretix.base.models import CartPosition
@@ -617,9 +624,9 @@ class Quota(LoggedModel):
return (
( # Orders for items which do not have any variations
Q(variation__isnull=True) &
Q(item__quotas__in=[self])
Q(item__quotas=self)
) | ( # Orders for items which do have any variations
Q(variation__quotas__in=[self])
Q(variation__quotas=self)
)
)

View File

@@ -7,6 +7,7 @@ from typing import List, Union
import pytz
from django.conf import settings
from django.db import models
from django.db.models import F
from django.utils.crypto import get_random_string
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
@@ -459,6 +460,8 @@ class OrderPosition(AbstractPosition):
@classmethod
def transform_cart_positions(cls, cp: List, order) -> list:
from . import Voucher
ops = []
for cartpos in cp:
op = OrderPosition(order=order)
@@ -471,8 +474,7 @@ class OrderPosition(AbstractPosition):
answ.cartposition = None
answ.save()
if cartpos.voucher:
cartpos.voucher.redeemed = True
cartpos.voucher.save()
Voucher.objects.filter(pk=cartpos.voucher.pk).update(redeemed=F('redeemed') + 1)
cartpos.delete()
return ops

View File

@@ -30,7 +30,9 @@ class Voucher(LoggedModel):
:type event: Event
:param code: The secret voucher code
:type code: str
:param redeemed: Whether or not this voucher has already been redeemed
:param max_usages: The number of times this voucher can be redeemed
:type max_usages: int
:param redeemed: The number of times this voucher already has been redeemed
:type redeemed: bool
:param valid_until: The expiration date of this voucher (optional)
:type valid_until: datetime
@@ -68,10 +70,14 @@ class Voucher(LoggedModel):
max_length=255, default=generate_code,
db_index=True,
)
redeemed = models.BooleanField(
max_usages = models.PositiveIntegerField(
verbose_name=_("Maximum usages"),
help_text=_("Number of times this voucher can be redeemed."),
default=1
)
redeemed = models.PositiveIntegerField(
verbose_name=_("Redeemed"),
default=False,
db_index=True
default=0
)
valid_until = models.DateTimeField(
blank=True, null=True, db_index=True,
@@ -197,7 +203,7 @@ class Voucher(LoggedModel):
Returns True if a voucher has not yet been redeemed, but is still
within its validity (if valid_until is set).
"""
if self.redeemed:
if self.redeemed >= self.max_usages:
return False
if self.valid_until and self.valid_until < now():
return False

View File

@@ -33,7 +33,8 @@ error_messages = {
'ended': _('The presale period has ended.'),
'price_too_high': _('The entered price is to high.'),
'voucher_invalid': _('This voucher code is not known in our database.'),
'voucher_redeemed': _('This voucher code has already been used and can only be used once.'),
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
'cart if you want to use it for a different product.'),
'voucher_expired': _('This voucher is expired.'),
@@ -114,17 +115,26 @@ def _add_new_items(event: Event, items: List[dict],
if i.get('voucher'):
try:
voucher = Voucher.objects.get(code=i.get('voucher').strip(), event=event)
if voucher.redeemed:
if voucher.redeemed >= voucher.max_usages:
return error_messages['voucher_redeemed']
if voucher.valid_until is not None and voucher.valid_until < now_dt:
return error_messages['voucher_expired']
if not voucher.applies_to(item, variation):
return error_messages['voucher_invalid_item']
doubleuse = CartPosition.objects.filter(voucher=voucher, cart_id=cart_id, event=event)
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=voucher) & Q(event=event) &
(Q(expires__gte=now_dt) | Q(cart_id=cart_id))
)
if 'cp' in i:
doubleuse = doubleuse.exclude(pk=i['cp'].pk)
if doubleuse.exists():
return error_messages['voucher_double']
redeemed_in_carts = redeemed_in_carts.exclude(pk=i['cp'].pk)
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
return error_messages['voucher_redeemed']
if i['count'] > v_avail:
return error_messages['voucher_redeemed_partial'] % v_avail
except Voucher.DoesNotExist:
return error_messages['voucher_invalid']

View File

@@ -8,6 +8,7 @@ from typing import List, Optional
import pytz
from celery.exceptions import MaxRetriesExceededError
from django.db import transaction
from django.db.models import F, Q
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.timezone import make_aware, now
@@ -18,7 +19,7 @@ from pretix.base.i18n import (
)
from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
User,
User, Voucher,
)
from pretix.base.models.orders import InvoiceAddress
from pretix.base.payment import BasePaymentProvider
@@ -46,11 +47,15 @@ error_messages = {
'server was too busy. Please try again.'),
'not_started': _('The presale period for this event has not yet started.'),
'ended': _('The presale period has ended.'),
'voucher_invalid': _('This voucher code is not known in our database.'),
'voucher_redeemed': _('This voucher code has already been used an can only be used once.'),
'voucher_expired': _('This voucher is expired.'),
'voucher_invalid_item': _('This voucher is not valid for this item.'),
'voucher_required': _('You need a valid voucher code to order one of the products in your cart.'),
'voucher_invalid': _('The voucher code used for one of the items in your cart is not known in our database.'),
'voucher_redeemed': _('The voucher code used for one of the items in your cart has already been used the maximum '
'number of times allowed. We removed this item from your cart.'),
'voucher_expired': _('The voucher code used for one of the items in your cart is expired. We removed this item '
'from your cart.'),
'voucher_invalid_item': _('The voucher code used for one of the items in your cart is not valid for this item. We '
'removed this item from your cart.'),
'voucher_required': _('You need a valid voucher code to order one of the products in your cart. We removed this '
'item from your cart.'),
}
logger = logging.getLogger(__name__)
@@ -163,8 +168,7 @@ def _cancel_order(order, user=None):
for position in order.positions.all():
if position.voucher:
position.voucher.redeemed = False
position.voucher.save()
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
return order
@@ -184,7 +188,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = None
_check_date(event, now_dt)
voucherids = set()
for i, cp in enumerate(positions):
if not cp.item.active or (cp.variation and not cp.variation.active):
err = err or error_messages['unavailable']
@@ -193,11 +196,14 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
if cp.voucher:
if cp.voucher.redeemed or cp.voucher_id in voucherids:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=cp.voucher) & Q(event=event) & Q(expires__gte=now_dt)
).exclude(pk=cp.pk)
v_avail = cp.voucher.max_usages - cp.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = err or error_messages['voucher_redeemed']
cp.delete() # Sorry! But you should have never gotten into this state at all.
cp.delete() # Sorry!
continue
voucherids.add(cp.voucher_id)
if cp.item.require_voucher and cp.voucher is None:
cp.delete()
@@ -225,6 +231,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
if cp.voucher:
if cp.voucher.valid_until and cp.voucher.valid_until < now_dt:
err = err or error_messages['voucher_expired']
cp.delete()
continue
if cp.voucher.price is not None:
price = cp.voucher.price

View File

@@ -23,7 +23,7 @@ class VoucherForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag',
'comment'
'comment', 'max_usages'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
@@ -81,11 +81,20 @@ class VoucherForm(I18nModelForm):
self.instance.item = None
self.instance.variation = None
if data['max_usages'] < self.instance.redeemed:
raise ValidationError(
_('This voucher has already been redeemed %(redeemed)s times. You cannot reduce the maximum number of '
'usages below this number.'),
params={
'redeemed': self.instance.redeemed
}
)
if 'codes' in data:
data['codes'] = [a.strip() for a in data.get('codes', '').strip().split("\n") if a]
cnt = len(data['codes'])
cnt = len(data['codes']) * data['max_usages']
else:
cnt = 1
cnt = data['max_usages']
if self._clean_quota_needs_checking(data):
self._clean_quota_check(data, cnt)
@@ -178,11 +187,18 @@ class VoucherBulkForm(VoucherForm):
model = Voucher
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment'
'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment',
'max_usages'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
}
labels = {
'max_usages': _('Maximum usages per voucher')
}
help_texts = {
'max_usages': _('Number of times times EACH of these vouchers can be redeemed.')
}
def clean(self):
data = super().clean()

View File

@@ -26,6 +26,7 @@
</div>
</div>
{% bootstrap_field form.codes layout="horizontal" %}
{% bootstrap_field form.max_usages layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Voucher details" %}</legend>

View File

@@ -23,6 +23,7 @@
<fieldset>
<legend>{% trans "Voucher details" %}</legend>
{% bootstrap_field form.code layout="horizontal" %}
{% bootstrap_field form.max_usages layout="horizontal" %}
{% bootstrap_field form.valid_until layout="horizontal" %}
{% bootstrap_field form.block_quota layout="horizontal" %}
{% bootstrap_field form.allow_ignore_quota layout="horizontal" %}

View File

@@ -54,7 +54,7 @@
<thead>
<tr>
<th>{% trans "Voucher code" %}</th>
<th>{% trans "Is redeemed" %}</th>
<th>{% trans "Redemptions" %}</th>
<th>{% trans "Expiry" %}</th>
<th>{% trans "Tag" %}</th>
<th>{% trans "Product" %}</th>
@@ -68,7 +68,7 @@
<strong><a href="
{% url "control:event.voucher" organizer=request.event.organizer.slug event=request.event.slug voucher=v.id %}">{{ v.code }}</a></strong>
</td>
<td>{% if v.redeemed %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
<td>{{ v.redeemed }} / {{ v.max_usages }}</td>
<td>{{ v.valid_until|date }}</td>
<td>
{{ v.tag }}

View File

@@ -5,7 +5,7 @@ from django.conf import settings
from django.contrib import messages
from django.core.urlresolvers import resolve, reverse
from django.db import transaction
from django.db.models import Case, Count, IntegerField, Q, Sum, When
from django.db.models import Count, Q, Sum
from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, HttpResponseRedirect,
JsonResponse,
@@ -41,11 +41,11 @@ class VoucherList(EventPermissionRequiredMixin, ListView):
if self.request.GET.get("status", "") != "":
s = self.request.GET.get("status", "")
if s == 'v':
qs = qs.filter(Q(valid_until__isnull=True) | Q(valid_until__gt=now())).filter(redeemed=False)
qs = qs.filter(Q(valid_until__isnull=True) | Q(valid_until__gt=now())).filter(redeemed=0)
elif s == 'r':
qs = qs.filter(redeemed=True)
qs = qs.filter(redeemed__gt=0)
elif s == 'e':
qs = qs.filter(Q(valid_until__isnull=False) & Q(valid_until__lt=now())).filter(redeemed=False)
qs = qs.filter(Q(valid_until__isnull=False) & Q(valid_until__lt=now())).filter(redeemed=0)
return qs
def get(self, request, *args, **kwargs):
@@ -59,7 +59,7 @@ class VoucherList(EventPermissionRequiredMixin, ListView):
headers = [
_('Voucher code'), _('Valid until'), _('Product'), _('Reserve quota'), _('Bypass quota'),
_('Price'), _('Tag'), _('Redeemed')
_('Price'), _('Tag'), _('Redeemed'), _('Maximum usages')
]
writer.writerow(headers)
@@ -79,7 +79,8 @@ class VoucherList(EventPermissionRequiredMixin, ListView):
_("Yes") if v.allow_ignore_quota else _("No"),
str(v.price) if v.price else "",
v.tag,
_("Yes") if v.redeemed else _("No"),
str(v.redeemed),
str(v.max_usages)
]
writer.writerow(row)
@@ -97,14 +98,7 @@ class VoucherTags(EventPermissionRequiredMixin, TemplateView):
tags = self.request.event.vouchers.order_by('tag').filter(tag__isnull=False).values('tag').annotate(
total=Count('id'),
# This is a fix for this MySQL issue: https://code.djangoproject.com/ticket/24662
redeemed=Sum(
Case(
When(redeemed=True, then=1),
When(redeemed=False, then=0),
output_field=IntegerField()
)
)
redeemed=Sum('redeemed')
)
for t in tags:
t['percentage'] = int((t['redeemed'] / t['total']) * 100)
@@ -128,7 +122,7 @@ class VoucherDelete(EventPermissionRequiredMixin, DeleteView):
raise Http404(_("The requested voucher does not exist."))
def get(self, request, *args, **kwargs):
if self.get_object().redeemed:
if self.get_object().redeemed > 0:
messages.error(request, _('A voucher can not be deleted if it already has been redeemed.'))
return HttpResponseRedirect(self.get_success_url())
return super().get(request, *args, **kwargs)
@@ -138,7 +132,7 @@ class VoucherDelete(EventPermissionRequiredMixin, DeleteView):
self.object = self.get_object()
success_url = self.get_success_url()
if self.object.redeemed:
if self.object.redeemed > 0:
messages.error(request, _('A voucher can not be deleted if it already has been redeemed.'))
else:
self.object.log_action('pretix.voucher.deleted', user=self.request.user)

View File

@@ -79,9 +79,14 @@
{% 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 }}">
{% if max_times > 1 %}
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
max="{{ item.order_max }}" name="variation_{{ item.id }}_{{ var.id }}">
{% else %}
<input type="radio" name="_voucher_item"
{% if options == 1 %}checked="checked"{% endif %}
value="variation_{{ item.id }}_{{ var.id }}">
{% endif %}
</label>
</div>
{% else %}
@@ -128,9 +133,14 @@
{% 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 }}">
{% if max_times > 1 %}
<input type="number" class="form-control input-item-count" placeholder="0" min="0"
max="{{ item.order_max }}" name="item_{{ item.id }}">
{% else %}
<input type="radio" name="_voucher_item"
{% if options == 1 %}checked="checked"{% endif %}
value="item_{{ item.id }}">
{% endif %}
</label>
</div>
{% else %}

View File

@@ -30,21 +30,14 @@ class CartActionMixin:
def get_error_url(self):
return self.get_next_url()
def _item_from_post_value(self, key, value):
def _item_from_post_value(self, key, value, voucher=None):
if value.strip() == '' or '_' not in key:
return
parts = key.split("_")
if parts[-1] == "voucher":
voucher = value
value = 1
parts = parts[:-1]
else:
voucher = None
if not key.startswith('item_') and not key.startswith('variation_'):
return
parts = key.split("_")
try:
amount = int(value)
except ValueError:
@@ -86,7 +79,7 @@ class CartActionMixin:
req_items = list(self.request.POST.lists())
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'],)
'%s' % self.request.POST['_voucher_item'], ('1',)
))
pass
@@ -94,7 +87,7 @@ class CartActionMixin:
for key, values in req_items:
for value in values:
try:
item = self._item_from_post_value(key, value)
item = self._item_from_post_value(key, value, self.request.POST.get('_voucher_code'))
except CartError as e:
messages.error(self.request, str(e))
return
@@ -169,6 +162,7 @@ class RedeemView(EventViewMixin, TemplateView):
context = super().get_context_data(**kwargs)
context['voucher'] = self.voucher
context['max_times'] = self.voucher.max_usages - self.voucher.redeemed
# Fetch all items
items = self.request.event.items.all().filter(
@@ -242,10 +236,18 @@ class RedeemView(EventViewMixin, TemplateView):
v = v.strip()
try:
self.voucher = Voucher.objects.get(code=v, event=request.event)
if self.voucher.redeemed:
if self.voucher.redeemed >= self.voucher.max_usages:
err = error_messages['voucher_redeemed']
if self.voucher.valid_until is not None and self.voucher.valid_until < now():
err = error_messages['voucher_expired']
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=self.voucher) & Q(event=request.event) &
(Q(expires__gte=now()) | Q(cart_id=request.session.session_key))
)
v_avail = self.voucher.max_usages - self.voucher.redeemed - redeemed_in_carts.count()
if v_avail < 1:
err = error_messages['voucher_redeemed']
except Voucher.DoesNotExist:
err = error_messages['voucher_invalid']
else: