From 99f3db04a98eafd705896dc0adbdf1bac2f5fec6 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Wed, 11 Dec 2019 15:58:22 +0100 Subject: [PATCH] Allow to redeem a voucher for an existing cart (#1517) * Allow to redeem a voucher for an existing cart * Bundle behaviour --- src/pretix/base/services/cart.py | 92 +++++++- .../pretixpresale/event/fragment_cart.html | 22 +- src/pretix/presale/urls.py | 1 + src/pretix/presale/views/__init__.py | 1 + src/pretix/presale/views/cart.py | 23 +- src/pretix/static/pretixpresale/js/ui/cart.js | 9 + .../static/pretixpresale/scss/_cart.scss | 6 + src/tests/presale/test_cart.py | 214 ++++++++++++++++++ 8 files changed, 365 insertions(+), 3 deletions(-) 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(