Fixed #106 -- added pay-what-you-want tickets

This commit is contained in:
Raphael Michel
2016-03-24 18:01:09 +01:00
parent abf5af4253
commit 112a309a0e
12 changed files with 249 additions and 50 deletions

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.9.2 on 2016-03-24 16:15
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0016_voucher_variation'),
]
operations = [
migrations.AlterModelOptions(
name='logentry',
options={'ordering': ('-datetime',)},
),
migrations.AddField(
model_name='item',
name='free_price',
field=models.BooleanField(default=False, help_text='If this option is active, your users can choose the price themselves. The price configured above is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect additional donations for your event.', verbose_name='Free price'),
),
]

View File

@@ -3,7 +3,7 @@ from datetime import datetime
from decimal import Decimal
from django.db import models
from django.db.models import Q, Case, Count, Sum, When
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
@@ -132,6 +132,13 @@ class Item(LoggedModel):
verbose_name=_("Default price"),
max_digits=7, decimal_places=2, null=True
)
free_price = models.BooleanField(
default=False,
verbose_name=_("Free price input"),
help_text=_("If this option is active, your users can choose the price themselves. The price configured above "
"is then interpreted as the minimum price a user has to enter. You could use this e.g. to collect "
"additional donations for your event.")
)
tax_rate = models.DecimalField(
verbose_name=_("Taxes included in percent"),
max_digits=7, decimal_places=2,

View File

@@ -1,4 +1,5 @@
from datetime import datetime, timedelta
from decimal import Decimal
from django.conf import settings
from django.db.models import Q
@@ -51,7 +52,7 @@ 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))
items.insert(0, (cp.item_id, cp.variation_id, 1, cp.price, cp))
positions.add(cp)
return positions
@@ -69,7 +70,7 @@ def _check_date(event: Event) -> None:
raise CartError(error_messages['ended'])
def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int]],
def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int, Optional[str]]],
cart_id: str, expiry: datetime) -> Optional[str]:
err = None
@@ -114,20 +115,24 @@ def _add_new_items(event: Event, items: List[Tuple[int, Optional[int], int]],
err = err or error_messages['in_part']
quota_ok = min(quota_ok, avail[1])
price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
if item.free_price and len(i) > 3 and i[3]:
custom_price = Decimal(i[3].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) > 3 and i[2] == 1:
if len(i) > 4 and i[2] == 1:
# Recreating
cp = i[3]
cp = i[4]
cp.expires = expiry
cp.price = item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price)
cp.price = price
cp.save()
else:
CartPosition.objects.create(
event=event, item=item, variation=variation,
price=item.default_price if variation is None else (
variation.default_price if variation.default_price is not None else item.default_price),
price=price,
expires=expiry,
cart_id=cart_id
)
@@ -161,7 +166,7 @@ def _add_voucher(event: Event, voucher: str, expiry: datetime, cart_id: str):
raise CartError(error_messages['voucher_invalid'])
def _add_items_to_cart(event: Event, items: List[Tuple[int, Optional[int], int]], cart_id: str=None,
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():
_check_date(event)
@@ -186,12 +191,12 @@ def _add_items_to_cart(event: Event, items: List[Tuple[int, Optional[int], int]]
_add_voucher(event, voucher, expiry, cart_id)
def add_items_to_cart(event: int, items: List[Tuple[int, Optional[int], int]], cart_id: str=None,
def add_items_to_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]], cart_id: str=None,
voucher: 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)
:param items: A list of tuple of the form (item id, variation id or None, number, custom_price)
:param session: Session ID of a guest
:param coupon: A coupon that should also be reeemed
:raises CartError: On any error that occured
@@ -203,19 +208,29 @@ def add_items_to_cart(event: int, items: List[Tuple[int, Optional[int], int]], c
raise CartError(error_messages['busy'])
def _remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int]], cart_id: str) -> None:
def _remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]],
cart_id: str) -> None:
with event.lock():
for item, variation, cnt in items:
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)
else:
cw &= Q(variation__isnull=True)
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
cp.delete()
# 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]
for cp in correctprice:
cp.delete()
cnt -= len(correctprice)
if cnt > 0:
for cp in CartPosition.objects.filter(cw).order_by("-price")[:cnt]:
cp.delete()
def remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int]], cart_id: str=None) -> None:
def remove_items_from_cart(event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]],
cart_id: str=None) -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
@@ -233,8 +248,8 @@ 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]], cart_id: str,
voucher: str=None):
def add_items_to_cart_task(self, event: int, items: List[Tuple[int, Optional[int], int, Optional[str]]],
cart_id: str, voucher: str=None):
event = Event.objects.get(id=event)
try:
try:
@@ -245,7 +260,8 @@ if settings.HAS_CELERY:
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[Tuple[int, Optional[int], int]],
cart_id: str):
event = Event.objects.get(id=event)
try:
try:

View File

@@ -189,7 +189,7 @@ def _check_positions(event: Event, dt: datetime, positions: List[CartPosition]):
if cp.voucher.price is not None:
price = cp.voucher.price
if price != cp.price:
if price != cp.price and not (cp.item.free_price and cp.price > price):
positions[i] = cp
cp.price = price
cp.save()

View File

@@ -95,6 +95,7 @@ class ItemFormGeneral(I18nModelForm):
'description',
'picture',
'default_price',
'free_price',
'tax_rate',
'available_from',
'available_until',

View File

@@ -2,34 +2,35 @@
{% load i18n %}
{% load bootstrap3 %}
{% block inside %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.active layout="horizontal" %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
<fieldset>
<legend>{% trans "General information" %}</legend>
{% bootstrap_field form.name layout="horizontal" %}
{% bootstrap_field form.active layout="horizontal" %}
{% if form.has_variations %}
{% bootstrap_field form.has_variations layout="horizontal" %}
{% endif %}
{% bootstrap_field form.category layout="horizontal" %}
{% bootstrap_field form.category layout="horizontal" %}
{% bootstrap_field form.admission layout="horizontal" %}
{% bootstrap_field form.description layout="horizontal" %}
{% bootstrap_field form.description layout="horizontal" %}
{% bootstrap_field form.picture layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Price settings" %}</legend>
{% bootstrap_field form.default_price layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.available_from layout="horizontal" %}
{% bootstrap_field form.available_until layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
</fieldset>
<fieldset>
<legend>{% trans "Price settings" %}</legend>
{% bootstrap_field form.default_price layout="horizontal" %}
{% bootstrap_field form.tax_rate layout="horizontal" %}
{% bootstrap_field form.free_price layout="horizontal" %}
</fieldset>
<fieldset>
<legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.available_from layout="horizontal" %}
{% bootstrap_field form.available_until layout="horizontal" %}
</fieldset>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
</div>
</form>
{% endblock %}

View File

@@ -28,12 +28,17 @@
<form action="{% eventurl event "presale:event.cart.remove" %}"
method="post" data-asynctask>
{% csrf_token %}
{% if line.variation %}
<input type="hidden" name="variation_{{ line.item.id }}_{{ line.variation.id }}"
value="1" />
<input type="hidden" name="price_{{ line.item.id }}_{{ line.variation.id }}"
value="{{ line.price }}" />
{% else %}
<input type="hidden" name="item_{{ line.item.id }}"
value="1" />
<input type="hidden" name="price_{{ line.item.id }}"
value="{{ line.price }}" />
{% endif %}
<button class="btn btn-mini btn-link"><i class="fa fa-minus"></i></button>
</form>
@@ -46,9 +51,13 @@
{% if line.variation %}
<input type="hidden" name="variation_{{ line.item.id }}_{{ line.variation.id }}"
value="1" />
<input type="hidden" name="price_{{ line.item.id }}_{{ line.variation.id }}"
value="{{ line.price }}" />
{% else %}
<input type="hidden" name="item_{{ line.item.id }}"
value="1" />
<input type="hidden" name="price_{{ line.item.id }}"
value="{{ line.price }}" />
{% endif %}
<button class="btn btn-mini btn-link"><i class="fa fa-plus"></i></button>
</form>

View File

@@ -76,7 +76,7 @@
{% 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 %}
{% 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 %}
@@ -101,7 +101,18 @@
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{{ event.currency }} {{ var.price|floatformat:2 }}
{% if item.free_price %}
<div class="input-group">
<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 %}
<br/>
<small>{% blocktrans trimmed with rate=item.tax_rate %}
@@ -135,13 +146,24 @@
</a>
{% endif %}
<strong>{{ item.name }}</strong>
{% if item.description %}<p class="description">{{ item.description }}</p>{% endif %}
{% if item.description %}
<p class="description">{{ item.description }}</p>{% endif %}
{% if event.settings.show_quota_left %}
{% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %}
{% endif %}
</div>
<div class="col-md-2 col-xs-6 price">
{{ event.currency }} {{ item.price|floatformat:2 }}
{% if item.free_price %}
<div class="input-group">
<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 %}
<br/>
<small>{% blocktrans trimmed with rate=item.tax_rate %}

View File

@@ -33,17 +33,19 @@ class CartActionMixin:
"""
items = []
for key, value in self.request.POST.items():
if value.strip() == '':
if value.strip() == '' or '_' not in key:
continue
price = self.request.POST.get('price_' + key.split("_", 1)[1], "")
if key.startswith('item_'):
try:
items.append((int(key.split("_")[1]), None, int(value)))
items.append((int(key.split("_")[1]), None, int(value), price))
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)))
items.append((int(key.split("_")[1]), int(key.split("_")[2]), int(value), price))
except ValueError:
messages.error(self.request, _('Please enter numbers only.'))
return []

View File

@@ -10,6 +10,9 @@
.input-item-count {
text-align: center;
}
.input-item-price {
text-align: right;
}
.availability-box {
text-align: center;

View File

@@ -57,6 +57,66 @@ class CartTest(CartTestMixin, TestCase):
self.assertIsNone(objs[0].variation)
self.assertEqual(objs[0].price, 23)
def test_free_price(self):
self.ticket.free_price = True
self.ticket.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'price_%d' % self.ticket.id: '24.00'
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content)
self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text)
self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text)
self.assertIn('24', doc.select('.cart .cart-row')[0].select('.price')[0].text)
self.assertIn('24', doc.select('.cart .cart-row')[0].select('.price')[1].text)
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, 24)
def test_free_price_only_if_allowed(self):
self.ticket.free_price = False
self.ticket.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'price_%d' % self.ticket.id: '24.00'
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content)
self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text)
self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text)
self.assertIn('23', doc.select('.cart .cart-row')[0].select('.price')[0].text)
self.assertIn('23', doc.select('.cart .cart-row')[0].select('.price')[1].text)
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, 23)
def test_free_price_lower_bound(self):
self.ticket.free_price = False
self.ticket.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '1',
'price_%d' % self.ticket.id: '12.00'
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content)
self.assertIn('Early-bird', doc.select('.cart .cart-row')[0].select('strong')[0].text)
self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text)
self.assertIn('23', doc.select('.cart .cart-row')[0].select('.price')[0].text)
self.assertIn('23', doc.select('.cart .cart-row')[0].select('.price')[1].text)
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, 23)
def test_variation(self):
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1'
@@ -75,6 +135,27 @@ class CartTest(CartTestMixin, TestCase):
self.assertEqual(objs[0].variation, self.shirt_red)
self.assertEqual(objs[0].price, 14)
def test_variation_free_price(self):
self.shirt.free_price = True
self.shirt.save()
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'variation_%d_%d' % (self.shirt.id, self.shirt_red.id): '1',
'price_%d_%d' % (self.shirt.id, self.shirt_red.id): '16',
}, follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
doc = BeautifulSoup(response.rendered_content)
self.assertIn('Shirt', doc.select('.cart .cart-row')[0].select('strong')[0].text)
self.assertIn('Red', doc.select('.cart .cart-row')[0].text)
self.assertIn('1', doc.select('.cart .cart-row')[0].select('.count')[0].text)
self.assertIn('16', doc.select('.cart .cart-row')[0].select('.price')[0].text)
self.assertIn('16', doc.select('.cart .cart-row')[0].select('.price')[1].text)
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)
self.assertEqual(objs[0].price, 16)
def test_count(self):
response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), {
'item_%d' % self.ticket.id: '2'

View File

@@ -217,6 +217,23 @@ class CheckoutTestCase(TestCase):
session[key] = value
session.save()
def test_free_price(self):
self.ticket.free_price = True
self.ticket.save()
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=42, expires=now() + timedelta(minutes=10)
)
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(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.filter(id=cr1.id).exists())
self.assertEqual(Order.objects.count(), 1)
self.assertEqual(OrderPosition.objects.count(), 1)
self.assertEqual(OrderPosition.objects.first().price, 42)
def test_confirm_in_time(self):
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
@@ -260,6 +277,22 @@ class CheckoutTestCase(TestCase):
cr1 = CartPosition.objects.get(id=cr1.id)
self.assertEqual(cr1.price, 24)
def test_confirm_free_price_increased(self):
self.ticket.default_price = 24
self.ticket.free_price = True
self.ticket.save()
cr1 = CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, expires=now() - timedelta(minutes=10)
)
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(len(doc.select(".alert-danger")), 1)
cr1 = CartPosition.objects.get(id=cr1.id)
self.assertEqual(cr1.price, 24)
def test_voucher(self):
v = Voucher.objects.create(item=self.ticket, price=Decimal('12.00'), event=self.event,
valid_until=now() + timedelta(days=2))