From 112a309a0e272b4170de2d22cc1360ff6eaa367d Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Thu, 24 Mar 2016 18:01:09 +0100 Subject: [PATCH] Fixed #106 -- added pay-what-you-want tickets --- .../migrations/0017_auto_20160324_1615.py | 24 ++++++ src/pretix/base/models/items.py | 9 ++- src/pretix/base/services/cart.py | 54 ++++++++----- src/pretix/base/services/orders.py | 2 +- src/pretix/control/forms/item.py | 1 + .../templates/pretixcontrol/item/index.html | 45 ++++++----- .../pretixpresale/event/fragment_cart.html | 9 +++ .../templates/pretixpresale/event/index.html | 30 ++++++- src/pretix/presale/views/cart.py | 8 +- src/static/pretixpresale/less/event.less | 3 + src/tests/presale/test_cart.py | 81 +++++++++++++++++++ src/tests/presale/test_checkout.py | 33 ++++++++ 12 files changed, 249 insertions(+), 50 deletions(-) create mode 100644 src/pretix/base/migrations/0017_auto_20160324_1615.py diff --git a/src/pretix/base/migrations/0017_auto_20160324_1615.py b/src/pretix/base/migrations/0017_auto_20160324_1615.py new file mode 100644 index 0000000000..d059935dfb --- /dev/null +++ b/src/pretix/base/migrations/0017_auto_20160324_1615.py @@ -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'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index d5d1d0cf28..6e2bc7aad1 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -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, diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 23cd9df727..d1b7be3b2e 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -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: diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 85e832b5a0..926fcc9469 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -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() diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 7d8cdbe4b9..95d5986004 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -95,6 +95,7 @@ class ItemFormGeneral(I18nModelForm): 'description', 'picture', 'default_price', + 'free_price', 'tax_rate', 'available_from', 'available_until', diff --git a/src/pretix/control/templates/pretixcontrol/item/index.html b/src/pretix/control/templates/pretixcontrol/item/index.html index 078026ef4b..72aecb79e3 100644 --- a/src/pretix/control/templates/pretixcontrol/item/index.html +++ b/src/pretix/control/templates/pretixcontrol/item/index.html @@ -2,34 +2,35 @@ {% load i18n %} {% load bootstrap3 %} {% block inside %} -
- {% csrf_token %} -
- {% trans "General information" %} - {% bootstrap_field form.name layout="horizontal" %} - {% bootstrap_field form.active layout="horizontal" %} + + {% csrf_token %} +
+ {% trans "General information" %} + {% 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" %} -
-
- {% trans "Price settings" %} - {% bootstrap_field form.default_price layout="horizontal" %} - {% bootstrap_field form.tax_rate layout="horizontal" %} -
-
- {% trans "Availability" %} - {% bootstrap_field form.available_from layout="horizontal" %} - {% bootstrap_field form.available_until layout="horizontal" %} -
-
+
+
+ {% trans "Price settings" %} + {% bootstrap_field form.default_price layout="horizontal" %} + {% bootstrap_field form.tax_rate layout="horizontal" %} + {% bootstrap_field form.free_price layout="horizontal" %} +
+
+ {% trans "Availability" %} + {% bootstrap_field form.available_from layout="horizontal" %} + {% bootstrap_field form.available_until layout="horizontal" %} +
+
-
-
+ + {% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 2ebe5714ae..b6576458e0 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -28,12 +28,17 @@
{% csrf_token %} + {% if line.variation %} + {% else %} + {% endif %}
@@ -46,9 +51,13 @@ {% if line.variation %} + {% else %} + {% endif %} diff --git a/src/pretix/presale/templates/pretixpresale/event/index.html b/src/pretix/presale/templates/pretixpresale/event/index.html index 50ea2f3bcd..1e4e5d47e3 100644 --- a/src/pretix/presale/templates/pretixpresale/event/index.html +++ b/src/pretix/presale/templates/pretixpresale/event/index.html @@ -76,7 +76,7 @@ {% if item.description %}

{{ item.description }}

{% endif %}
- {% 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 %}
- {{ event.currency }} {{ var.price|floatformat:2 }} + {% if item.free_price %} +
+ {{ event.currency }} + +
+ {% else %} + {{ event.currency }} {{ var.price|floatformat:2 }} + {% endif %} {% if item.tax_rate %}
{% blocktrans trimmed with rate=item.tax_rate %} @@ -135,13 +146,24 @@ {% endif %} {{ item.name }} - {% if item.description %}

{{ item.description }}

{% endif %} + {% if item.description %} +

{{ item.description }}

{% endif %} {% if event.settings.show_quota_left %} {% include "pretixpresale/event/fragment_quota_left.html" with avail=item.cached_availability %} {% endif %}
- {{ event.currency }} {{ item.price|floatformat:2 }} + {% if item.free_price %} +
+ {{ event.currency }} + +
+ {% else %} + {{ event.currency }} {{ item.price|floatformat:2 }} + {% endif %} {% if item.tax_rate %}
{% blocktrans trimmed with rate=item.tax_rate %} diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 568d196728..1035228916 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -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 [] diff --git a/src/static/pretixpresale/less/event.less b/src/static/pretixpresale/less/event.less index ae9da092f3..56786a779e 100644 --- a/src/static/pretixpresale/less/event.less +++ b/src/static/pretixpresale/less/event.less @@ -10,6 +10,9 @@ .input-item-count { text-align: center; } + .input-item-price { + text-align: right; + } .availability-box { text-align: center; diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index db017d42c5..81a7a4368e 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -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' diff --git a/src/tests/presale/test_checkout.py b/src/tests/presale/test_checkout.py index 58e2ce6347..7ab1928812 100644 --- a/src/tests/presale/test_checkout.py +++ b/src/tests/presale/test_checkout.py @@ -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))