Skip to content

Commit

Permalink
Refs #145 -- Vouchers that grant discounts
Browse files Browse the repository at this point in the history
  • Loading branch information
raphaelm committed Nov 29, 2016
1 parent a8be2d5 commit c865020
Show file tree
Hide file tree
Showing 14 changed files with 234 additions and 74 deletions.
30 changes: 30 additions & 0 deletions src/pretix/base/migrations/0048_auto_20161129_1330.py
@@ -0,0 +1,30 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.10.3 on 2016-11-29 13:30
from __future__ import unicode_literals

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('pretixbase', '0047_auto_20161126_1300'),
]

operations = [
migrations.AddField(
model_name='voucher',
name='price_mode',
field=models.CharField(choices=[('none', 'No effect'), ('set', 'Set product price to'), ('subtract', 'Subtract from product price'), ('percent', 'Reduce product price by (%)')], default='set', max_length=100, verbose_name='Price mode'),
),
migrations.RenameField(
model_name='voucher',
old_name='price',
new_name='value',
),
migrations.AlterField(
model_name='voucher',
name='value',
field=models.DecimalField(blank=True, decimal_places=2, max_digits=10, null=True, verbose_name='Voucher value'),
),
]
37 changes: 34 additions & 3 deletions src/pretix/base/models/vouchers.py
@@ -1,10 +1,13 @@
from decimal import Decimal

from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _

from ..decimal import round_decimal
from .base import LoggedModel
from .event import Event
from .items import Item, ItemVariation, Quota
Expand Down Expand Up @@ -59,6 +62,13 @@ class Voucher(LoggedModel):
* You need to either select a quota or an item
* If you select an item that has variations but do not select a variation, you cannot set block_quota
"""
PRICE_MODES = (
('none', _('No effect')),
('set', _('Set product price to')),
('subtract', _('Subtract from product price')),
('percent', _('Reduce product price by (%)')),
)

event = models.ForeignKey(
Event,
on_delete=models.CASCADE,
Expand Down Expand Up @@ -98,10 +108,15 @@ class Voucher(LoggedModel):
"If activated, a holder of this voucher code can buy tickets, even if there are none left."
)
)
price = models.DecimalField(
verbose_name=_("Set product price to"),
price_mode = models.CharField(
verbose_name=_("Price mode"),
max_length=100,
choices=PRICE_MODES,
default='set'
)
value = models.DecimalField(
verbose_name=_("Voucher value"),
decimal_places=2, max_digits=10, null=True, blank=True,
help_text=_('If empty, the product will cost its normal price.')
)
item = models.ForeignKey(
Item, related_name='vouchers',
Expand Down Expand Up @@ -208,3 +223,19 @@ def is_active(self):
if self.valid_until and self.valid_until < now():
return False
return True

def calculate_price(self, original_price: Decimal) -> Decimal:
"""
Returns how the price given in original_price would be modified if this
voucher is applied, i.e. replaced by a different price or reduced by a
certain percentage. If the voucher does not modify the price, the
original price will be returned.
"""
if self.value:
if self.price_mode == 'set':
return self.value
elif self.price_mode == 'subtract':
return original_price - self.value
elif self.price_mode == 'percent':
return round_decimal(original_price * (Decimal('100.00') - self.value) / Decimal('100.00'))
return original_price
9 changes: 4 additions & 5 deletions src/pretix/base/services/cart.py
Expand Up @@ -168,11 +168,10 @@ def _add_new_items(event: Event, items: List[dict],
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)
price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
if voucher:
price = voucher.calculate_price(price)

if item.free_price and 'price' in i and i['price'] is not None and i['price'] != "":
custom_price = i['price']
Expand Down
3 changes: 1 addition & 2 deletions src/pretix/base/services/orders.py
Expand Up @@ -233,8 +233,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
err = err or error_messages['voucher_expired']
cp.delete()
continue
if cp.voucher.price is not None:
price = cp.voucher.price
price = cp.voucher.calculate_price(price)

if price != cp.price and not (cp.item.free_price and cp.price > price):
positions[i] = cp
Expand Down
8 changes: 4 additions & 4 deletions src/pretix/control/forms/vouchers.py
Expand Up @@ -22,8 +22,8 @@ class Meta:
model = Voucher
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag',
'comment', 'max_usages'
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'price_mode'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
Expand Down Expand Up @@ -187,8 +187,8 @@ class Meta:
model = Voucher
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'price', 'tag', 'comment',
'max_usages'
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'price_mode'
]
widgets = {
'valid_until': forms.DateTimeInput(attrs={'class': 'datetimepicker'}),
Expand Down
10 changes: 9 additions & 1 deletion src/pretix/control/templates/pretixcontrol/vouchers/bulk.html
Expand Up @@ -33,7 +33,15 @@ <h1>{% trans "Create multiple voucher" %}</h1>
{% bootstrap_field form.valid_until layout="horizontal" %}
{% bootstrap_field form.block_quota layout="horizontal" %}
{% bootstrap_field form.allow_ignore_quota layout="horizontal" %}
{% bootstrap_field form.price layout="horizontal" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
{% bootstrap_field form.itemvar layout="horizontal" %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
Expand Down
10 changes: 9 additions & 1 deletion src/pretix/control/templates/pretixcontrol/vouchers/detail.html
Expand Up @@ -27,7 +27,15 @@ <h1>{% trans "Voucher" %}</h1>
{% bootstrap_field form.valid_until layout="horizontal" %}
{% bootstrap_field form.block_quota layout="horizontal" %}
{% bootstrap_field form.allow_ignore_quota layout="horizontal" %}
{% bootstrap_field form.price layout="horizontal" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_tag">{% trans "Price effect" %}</label>
<div class="col-md-5">
{% bootstrap_field form.price_mode show_label=False form_group_class="" %}
</div>
<div class="col-md-4">
{% bootstrap_field form.value show_label=False form_group_class="" %}
</div>
</div>
{% bootstrap_field form.itemvar layout="horizontal" %}
<div class="form-group">
<div class="col-md-9 col-md-offset-3">
Expand Down
5 changes: 3 additions & 2 deletions src/pretix/control/views/vouchers.py
Expand Up @@ -59,7 +59,7 @@ def _download_csv(self):

headers = [
_('Voucher code'), _('Valid until'), _('Product'), _('Reserve quota'), _('Bypass quota'),
_('Price'), _('Tag'), _('Redeemed'), _('Maximum usages')
_('Price effect'), _('Value'), _('Tag'), _('Redeemed'), _('Maximum usages')
]
writer.writerow(headers)

Expand All @@ -77,7 +77,8 @@ def _download_csv(self):
prod,
_("Yes") if v.block_quota else _("No"),
_("Yes") if v.allow_ignore_quota else _("No"),
str(v.price) if v.price else "",
v.get_price_mode_display(),
str(v.value) if v.value else "",
v.tag,
str(v.redeemed),
str(v.max_usages)
Expand Down
12 changes: 4 additions & 8 deletions src/pretix/presale/views/cart.py
Expand Up @@ -198,20 +198,16 @@ def get_context_data(self, **kwargs):
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
item.price = self.voucher.calculate_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
var.price = self.voucher.calculate_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])
Expand Down
24 changes: 24 additions & 0 deletions src/tests/base/test_models.py
@@ -1,6 +1,7 @@
import datetime
import sys
from datetime import timedelta
from decimal import Decimal

import pytest
from django.conf import settings
Expand Down Expand Up @@ -406,6 +407,29 @@ def test_voucher_no_item_but_variation(self):
v.clean()


class VoucherTestCase(BaseQuotaTestCase):

def test_calculate_price_none(self):
v = Voucher.objects.create(event=self.event, price_mode='none', value=Decimal('10.00'))
v.calculate_price(Decimal('23.42')) == Decimal('23.42')

def test_calculate_price_set_empty(self):
v = Voucher.objects.create(event=self.event, price_mode='set')
v.calculate_price(Decimal('23.42')) == Decimal('23.42')

def test_calculate_price_set(self):
v = Voucher.objects.create(event=self.event, price_mode='set', value=Decimal('10.00'))
v.calculate_price(Decimal('23.42')) == Decimal('10.00')

def test_calculate_price_subtract(self):
v = Voucher.objects.create(event=self.event, price_mode='subtract', value=Decimal('10.00'))
v.calculate_price(Decimal('23.42')) == Decimal('13.42')

def test_calculate_price_percent(self):
v = Voucher.objects.create(event=self.event, price_mode='percent', value=Decimal('23.00'))
v.calculate_price(Decimal('100.00')) == Decimal('77.00')


class OrderTestCase(BaseQuotaTestCase):
def setUp(self):
super().setUp()
Expand Down
6 changes: 3 additions & 3 deletions src/tests/control/test_vouchers.py
Expand Up @@ -80,9 +80,9 @@ def test_list(self):
def test_csv(self):
self.event.vouchers.create(item=self.ticket, code='ABCDEFG')
doc = self.client.get('/control/event/%s/%s/vouchers/?download=yes' % (self.orga.slug, self.event.slug))
assert doc.content.strip() == '"Voucher code","Valid until","Product","Reserve quota","Bypass quota","Price",' \
'"Tag","Redeemed","Maximum usages"\r\n"ABCDEFG","","Early-bird ticket","No",' \
'"No","","","0","1"'.encode('utf-8')
assert doc.content.strip() == '"Voucher code","Valid until","Product","Reserve quota","Bypass quota",' \
'"Price effect","Value","Tag","Redeemed","Maximum usages"\r\n"ABCDEFG","",' \
'"Early-bird ticket","No","No","Set product price to","","","0","1"'.encode('utf-8')

def test_filter_status_valid(self):
v = self.event.vouchers.create(item=self.ticket)
Expand Down

0 comments on commit c865020

Please sign in to comment.