diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py
index fcb65ec991..8ff5c00019 100644
--- a/src/pretix/base/services/cart.py
+++ b/src/pretix/base/services/cart.py
@@ -82,6 +82,9 @@ error_messages = {
'voucher_expired': _('This voucher is expired.'),
'voucher_invalid_item': _('This voucher is not valid for this product.'),
'voucher_invalid_seat': _('This voucher is not valid for this seat.'),
+ 'voucher_no_match': _('We did not find any position in your cart that we could use this voucher for. If you want '
+ 'to add something new to your cart using that voucher, you can do so with the voucher '
+ 'redemption option on the bottom of the page.'),
'voucher_item_not_available': _(
'Your voucher is valid for a product that is currently not for sale.'),
'voucher_invalid_subevent': pgettext_lazy('subevent', 'This voucher is not valid for this event date.'),
@@ -107,10 +110,12 @@ class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
RemoveOperation = namedtuple('RemoveOperation', ('position',))
+ VoucherOperation = namedtuple('VoucherOperation', ('position', 'voucher', 'price'))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent', 'seat'))
order = {
RemoveOperation: 10,
+ VoucherOperation: 15,
ExtendOperation: 20,
AddOperation: 30
}
@@ -419,6 +424,58 @@ class CartManager:
self._operations.append(op)
return err
+ def apply_voucher(self, voucher_code: str):
+ if self._operations:
+ raise CartError('Applying a voucher to the whole cart should not be combined with other operations.')
+ try:
+ voucher = self.event.vouchers.get(code__iexact=voucher_code.strip())
+ except Voucher.DoesNotExist:
+ raise CartError(error_messages['voucher_invalid'])
+ voucher_use_diff = Counter()
+ ops = []
+
+ if not voucher.is_active():
+ raise CartError(error_messages['voucher_expired'])
+
+ for p in self.positions:
+ if p.voucher_id:
+ continue
+
+ if not voucher.applies_to(p.item, p.variation):
+ continue
+
+ if voucher.seat and voucher.seat != p.seat:
+ continue
+
+ if voucher.subevent_id and voucher.subevent_id != p.subevent_id:
+ continue
+
+ if p.is_bundled:
+ continue
+
+ bundled_sum = Decimal('0.00')
+ if not p.addon_to_id:
+ for bundledp in p.addons.all():
+ if bundledp.is_bundled:
+ bundledprice = bundledp.price
+ bundled_sum += bundledprice
+
+ price = self._get_price(p.item, p.variation, voucher, None, p.subevent, bundled_sum=bundled_sum)
+ if price.gross > p.price:
+ continue
+
+ voucher_use_diff[voucher] += 1
+ ops.append((p.price - price.gross, self.VoucherOperation(p, voucher, price)))
+
+ # If there are not enough voucher usages left for the full cart, let's apply them in the order that benefits
+ # the user the most.
+ ops.sort(key=lambda k: k[0], reverse=True)
+ self._operations += [k[1] for k in ops]\
+
+ if not voucher_use_diff:
+ raise CartError(error_messages['voucher_no_match'])
+ self._voucher_use_diff += voucher_use_diff
+
def add_new_items(self, items: List[dict]):
# Fetch items from the database
self._update_items_cache([i['item'] for i in items], [i['variation'] for i in items])
@@ -762,7 +819,7 @@ class CartManager:
self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set()
- for op in self._operations:
+ for iop, op in enumerate(self._operations):
if isinstance(op, self.RemoveOperation):
if op.position.expires > self.now_dt:
for q in op.position.quotas:
@@ -896,6 +953,19 @@ class CartManager:
op.position.delete()
else:
raise AssertionError("ExtendOperation cannot affect more than one item")
+ elif isinstance(op, self.VoucherOperation):
+ if vouchers_ok[op.voucher] < 1:
+ if iop == 0:
+ raise CartError(error_messages['voucher_redeemed'])
+ else:
+ # We fail silently if we could only apply the voucher to part of the cart, since that might
+ # be expected
+ continue
+
+ op.position.price = op.price.gross
+ op.position.voucher = op.voucher
+ op.position.save()
+ vouchers_ok[op.voucher] -= 1
for p in new_cart_positions:
if getattr(p, '_answers', None):
@@ -1060,6 +1130,26 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
raise CartError(error_messages['busy'])
+@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
+def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='en') -> None:
+ """
+ Removes a list of items from a user's cart.
+ :param event: The event ID in question
+ :param voucher: A voucher code
+ :param session: Session ID of a guest
+ """
+ with language(locale):
+ try:
+ try:
+ cm = CartManager(event=event, cart_id=cart_id)
+ cm.apply_voucher(voucher)
+ cm.commit()
+ except LockTimeoutException:
+ self.retry()
+ except (MaxRetriesExceededError, LockTimeoutException):
+ raise CartError(error_messages['busy'])
+
+
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en') -> None:
"""
diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html
index e15f4ea408..2ce8a79797 100644
--- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html
+++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html
@@ -246,11 +246,31 @@
{% trans "Total" %}
-
\ No newline at end of file
diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py
index 89756401fa..69ccb4c199 100644
--- a/src/pretix/presale/urls.py
+++ b/src/pretix/presale/urls.py
@@ -18,6 +18,7 @@ import pretix.presale.views.widget
frame_wrapped_urls = [
url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'),
+ url(r'^cart/voucher$', pretix.presale.views.cart.CartApplyVoucher.as_view(), name='event.cart.voucher'),
url(r'^cart/clear$', pretix.presale.views.cart.CartClear.as_view(), name='event.cart.clear'),
url(r'^cart/answer/(?P[^/]+)/$',
pretix.presale.views.cart.AnswerDownload.as_view(),
diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py
index 528be0e3d8..a57b79ca10 100644
--- a/src/pretix/presale/views/__init__.py
+++ b/src/pretix/presale/views/__init__.py
@@ -164,6 +164,7 @@ class CartMixin:
return {
'positions': positions,
+ 'all_with_voucher': all(p.voucher_id for p in positions),
'raw': cartpos,
'total': total,
'net_total': net_total,
diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py
index 0441762c66..1efc6f0929 100644
--- a/src/pretix/presale/views/cart.py
+++ b/src/pretix/presale/views/cart.py
@@ -23,7 +23,8 @@ from pretix.base.models import (
CartPosition, InvoiceAddress, QuestionAnswer, SubEvent, Voucher,
)
from pretix.base.services.cart import (
- CartError, add_items_to_cart, clear_cart, remove_cart_position,
+ CartError, add_items_to_cart, apply_voucher, clear_cart,
+ remove_cart_position,
)
from pretix.base.views.tasks import AsyncAction
from pretix.multidomain.urlreverse import eventreverse
@@ -327,6 +328,26 @@ def cart_session(request):
return request.session['carts'][cart_id]
+@method_decorator(allow_frame_if_namespaced, 'dispatch')
+class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
+ task = apply_voucher
+ known_errortypes = ['CartError']
+
+ def get_success_message(self, value):
+ return _('We applied the voucher to as many products in your cart as we could.')
+
+ def post(self, request, *args, **kwargs):
+ if 'voucher' in request.POST:
+ return self.do(self.request.event.id, request.POST.get('voucher'), get_or_create_cart_id(self.request), translation.get_language())
+ else:
+ if 'ajax' in self.request.GET or 'ajax' in self.request.POST:
+ return JsonResponse({
+ 'redirect': self.get_error_url()
+ })
+ else:
+ return redirect(self.get_error_url())
+
+
@method_decorator(allow_frame_if_namespaced, 'dispatch')
class CartRemove(EventViewMixin, CartActionMixin, AsyncAction, View):
task = remove_cart_position
diff --git a/src/pretix/static/pretixpresale/js/ui/cart.js b/src/pretix/static/pretixpresale/js/ui/cart.js
index d5b9b98ad2..62e231869a 100644
--- a/src/pretix/static/pretixpresale/js/ui/cart.js
+++ b/src/pretix/static/pretixpresale/js/ui/cart.js
@@ -70,4 +70,13 @@ $(function () {
if ($("#cart-deadline").length) {
cart.init();
}
+
+ $(".apply-voucher").hide();
+ $(".apply-voucher-toggle").click(function (e) {
+ $(".apply-voucher-toggle").hide();
+ $(".apply-voucher").show();
+ $(".apply-voucher input[ŧype=text]").first().focus();
+ e.preventDefault();
+ return true;
+ });
});
diff --git a/src/pretix/static/pretixpresale/scss/_cart.scss b/src/pretix/static/pretixpresale/scss/_cart.scss
index 8d50c059f9..e3a58e87c2 100644
--- a/src/pretix/static/pretixpresale/scss/_cart.scss
+++ b/src/pretix/static/pretixpresale/scss/_cart.scss
@@ -73,6 +73,12 @@
}
}
+.apply-voucher {
+ input {
+ height: 32px;
+ }
+}
+
@media(max-width: $screen-sm-max) {
.cart-row {
.download-mobile {
diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py
index e553c9025f..1a1e5ef64d 100644
--- a/src/tests/presale/test_cart.py
+++ b/src/tests/presale/test_cart.py
@@ -1725,6 +1725,178 @@ class CartTest(CartTestMixin, TestCase):
positions = CartPosition.objects.filter(cart_id=self.session_key, event=self.event)
assert positions.count() == 1
+ def test_voucher_apply_matching(self):
+ with scopes_disabled():
+ cp1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=23, expires=now() + timedelta(minutes=10)
+ )
+ cp2 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
+ price=15, expires=now() + timedelta(minutes=10)
+ )
+ v = Voucher.objects.create(
+ event=self.event, item=self.ticket, price_mode='set', value=Decimal('4.00')
+ )
+ response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
+ 'voucher': v.code,
+ }, follow=True)
+ assert 'alert-success' in response.rendered_content
+ with scopes_disabled():
+ cp1.refresh_from_db()
+ cp2.refresh_from_db()
+ assert cp1.voucher == v
+ assert cp1.price == Decimal('4.00')
+ assert cp2.voucher is None
+
+ def test_voucher_apply_partial_in_price_order(self):
+ with scopes_disabled():
+ cp1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=23, expires=now() + timedelta(minutes=10)
+ )
+ cp2 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
+ price=150, expires=now() + timedelta(minutes=10)
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('4.00'), max_usages=100, redeemed=99
+ )
+ response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
+ 'voucher': v.code,
+ }, follow=True)
+ assert 'alert-success' in response.rendered_content
+ with scopes_disabled():
+ cp1.refresh_from_db()
+ cp2.refresh_from_db()
+ assert cp1.voucher is None
+ assert cp1.price == Decimal('23.00')
+ assert cp2.voucher == v
+ assert cp2.price == Decimal('4.00')
+
+ def test_voucher_apply_multiple(self):
+ with scopes_disabled():
+ cp1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=23, expires=now() + timedelta(minutes=10)
+ )
+ cp2 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
+ price=150, expires=now() + timedelta(minutes=10)
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('4.00'), max_usages=100
+ )
+ response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
+ 'voucher': v.code,
+ }, follow=True)
+ assert 'alert-success' in response.rendered_content
+ with scopes_disabled():
+ cp1.refresh_from_db()
+ cp2.refresh_from_db()
+ assert cp1.voucher == v
+ assert cp1.price == Decimal('4.00')
+ assert cp2.voucher == v
+ assert cp2.price == Decimal('4.00')
+
+ def test_voucher_apply_only_one_per_line(self):
+ with scopes_disabled():
+ cp1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=23, expires=now() + timedelta(minutes=10)
+ )
+ v2 = Voucher.objects.create(
+ event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('4.00'), max_usages=100
+ )
+ cp2 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
+ price=150, expires=now() + timedelta(minutes=10), voucher=v2
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', quota=self.quota_all, value=Decimal('4.00'), max_usages=100
+ )
+ response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
+ 'voucher': v.code,
+ }, follow=True)
+ assert 'alert-success' in response.rendered_content
+ with scopes_disabled():
+ cp1.refresh_from_db()
+ cp2.refresh_from_db()
+ assert cp1.voucher == v
+ assert cp1.price == Decimal('4.00')
+ assert cp2.voucher == v2
+ assert cp2.price == Decimal('150.00')
+
+ def test_voucher_apply_only_positive(self):
+ with scopes_disabled():
+ cp1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=23, expires=now() + timedelta(minutes=10)
+ )
+ cp2 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
+ price=15, expires=now() + timedelta(minutes=10)
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('40.00'), max_usages=100
+ )
+ response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
+ 'voucher': v.code,
+ }, follow=True)
+ assert 'alert-danger' in response.rendered_content
+ with scopes_disabled():
+ cp1.refresh_from_db()
+ cp2.refresh_from_db()
+ assert cp1.voucher is None
+ assert cp2.voucher is None
+
+ def test_voucher_apply_expired(self):
+ with scopes_disabled():
+ cp1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=23, expires=now() + timedelta(minutes=10)
+ )
+ cp2 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
+ price=15, expires=now() + timedelta(minutes=10)
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('40.00'), max_usages=100,
+ valid_until=now() - timedelta(days=1)
+ )
+ response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
+ 'voucher': v.code,
+ }, follow=True)
+ assert 'alert-danger' in response.rendered_content
+ with scopes_disabled():
+ cp1.refresh_from_db()
+ cp2.refresh_from_db()
+ assert cp1.voucher is None
+ assert cp2.voucher is None
+
+ def test_voucher_apply_used(self):
+ with scopes_disabled():
+ cp1 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=23, expires=now() + timedelta(minutes=10)
+ )
+ cp2 = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
+ price=15, expires=now() + timedelta(minutes=10)
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('40.00'), max_usages=100, redeemed=100
+ )
+ response = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
+ 'voucher': v.code,
+ }, follow=True)
+ assert 'alert-danger' in response.rendered_content
+ with scopes_disabled():
+ cp1.refresh_from_db()
+ cp2.refresh_from_db()
+ assert cp1.voucher is None
+ assert cp2.voucher is None
+
class CartAddonTest(CartTestMixin, TestCase):
@scopes_disabled()
@@ -2685,6 +2857,48 @@ class CartBundleTest(CartTestMixin, TestCase):
assert cp.price == 0
assert b.price == 40
+ @classscope(attr='orga')
+ def test_voucher_apply_multiple(self):
+ cp = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=21.5, expires=now() + timedelta(minutes=10)
+ )
+ b = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
+ price=1.5, expires=now() + timedelta(minutes=10), is_bundled=True
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('4.00'), max_usages=100
+ )
+
+ self.cm.apply_voucher(v.code)
+ self.cm.commit()
+ cp.refresh_from_db()
+ b.refresh_from_db()
+ assert cp.price == Decimal('2.50')
+ assert b.price == Decimal('1.50')
+
+ @classscope(attr='orga')
+ def test_voucher_apply_multiple_reduce_beyond_designated_price(self):
+ cp = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.ticket,
+ price=21.5, expires=now() + timedelta(minutes=10)
+ )
+ b = CartPosition.objects.create(
+ event=self.event, cart_id=self.session_key, item=self.trans, addon_to=cp,
+ price=1.5, expires=now() + timedelta(minutes=10), is_bundled=True
+ )
+ v = Voucher.objects.create(
+ event=self.event, price_mode='set', value=Decimal('0.00'), max_usages=100
+ )
+
+ self.cm.apply_voucher(v.code)
+ self.cm.commit()
+ cp.refresh_from_db()
+ b.refresh_from_db()
+ assert cp.price == Decimal('0.00')
+ assert b.price == Decimal('1.50')
+
@classscope(attr='orga')
def test_extend_base_price_changed(self):
cp = CartPosition.objects.create(