Allow to enter gift cards into the voucher input (Z#23171961) (#4670)

This commit is contained in:
Raphael Michel
2024-12-05 13:43:46 +01:00
committed by GitHub
parent 94a64ba53a
commit 6b199a2b9c
8 changed files with 192 additions and 58 deletions

View File

@@ -1419,50 +1419,51 @@ class GiftCardPayment(BasePaymentProvider):
def payment_refund_supported(self, payment: OrderPayment) -> bool: def payment_refund_supported(self, payment: OrderPayment) -> bool:
return True return True
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]: def _add_giftcard_to_cart(self, cs, gc):
from pretix.base.services.cart import add_payment_to_cart from pretix.base.services.cart import add_payment_to_cart_session
if gc.currency != self.event.currency:
raise ValidationError(_("This gift card does not support this currency."))
if gc.testmode and not self.event.testmode:
raise ValidationError(_("This gift card can only be used in test mode."))
if not gc.testmode and self.event.testmode:
raise ValidationError(_("Only test gift cards can be used in test mode."))
if gc.expires and gc.expires < time_machine_now():
raise ValidationError(_("This gift card is no longer valid."))
if gc.value <= Decimal("0.00"):
raise ValidationError(_("All credit on this gift card has been used."))
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
raise ValidationError(_("This gift card is already used for your payment."))
add_payment_to_cart_session(
cs,
self,
max_value=gc.value,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
}
)
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str, None]:
for p in get_cart(request): for p in get_cart(request):
if p.item.issue_giftcard: if p.item.issue_giftcard:
messages.error(request, _("You cannot pay with gift cards when buying a gift card.")) messages.error(request, _("You cannot pay with gift cards when buying a gift card."))
return return
cs = cart_session(request)
try: try:
gc = self.event.organizer.accepted_gift_cards.get( gc = self.event.organizer.accepted_gift_cards.get(
secret=request.POST.get("giftcard").strip() secret=request.POST.get("giftcard").strip()
) )
if gc.currency != self.event.currency: cs = cart_session(request)
messages.error(request, _("This gift card does not support this currency.")) try:
self._add_giftcard_to_cart(cs, gc)
return True
except ValidationError as e:
messages.error(request, str(e.message))
return return
if gc.testmode and not self.event.testmode:
messages.error(request, _("This gift card can only be used in test mode."))
return
if not gc.testmode and self.event.testmode:
messages.error(request, _("Only test gift cards can be used in test mode."))
return
if gc.expires and gc.expires < time_machine_now():
messages.error(request, _("This gift card is no longer valid."))
return
if gc.value <= Decimal("0.00"):
messages.error(request, _("All credit on this gift card has been used."))
return
for p in cs.get('payments', []):
if p['provider'] == self.identifier and p['info_data']['gift_card'] == gc.pk:
messages.error(request, _("This gift card is already used for your payment."))
return
add_payment_to_cart(
request,
self,
max_value=gc.value,
info_data={
'gift_card': gc.pk,
'gift_card_secret': gc.secret,
}
)
return True
except GiftCard.DoesNotExist: except GiftCard.DoesNotExist:
if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists(): if self.event.vouchers.filter(code__iexact=request.POST.get("giftcard")).exists():
messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below " messages.warning(request, _("You entered a voucher instead of a gift card. Vouchers can only be entered on the first page of the shop below "

View File

@@ -1426,6 +1426,28 @@ class CartManager:
raise CartError(err) raise CartError(err)
def add_payment_to_cart_session(cart_session, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
"""
:param cart_session: The current cart session.
:param provider: The instance of your payment provider.
:param min_value: The minimum value this payment instrument supports, or ``None`` for unlimited.
:param max_value: The maximum value this payment instrument supports, or ``None`` for unlimited. Highly discouraged
to use for payment providers which charge a payment fee, as this can be very user-unfriendly if
users need a second payment method just for the payment fee of the first method.
:param info_data: A dictionary of information that will be passed through to the ``OrderPayment.info_data`` attribute.
:return:
"""
cart_session.setdefault('payments', [])
cart_session['payments'].append({
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': provider.multi_use_supported,
'min_value': str(min_value) if min_value is not None else None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None): def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: Decimal=None, info_data: dict=None):
""" """
:param request: The current HTTP request context. :param request: The current HTTP request context.
@@ -1440,16 +1462,7 @@ def add_payment_to_cart(request, provider, min_value: Decimal=None, max_value: D
from pretix.presale.views.cart import cart_session from pretix.presale.views.cart import cart_session
cs = cart_session(request) cs = cart_session(request)
cs.setdefault('payments', []) add_payment_to_cart_session(cs, provider, min_value, max_value, info_data)
cs['payments'].append({
'id': str(uuid.uuid4()),
'provider': provider.identifier,
'multi_use_supported': provider.multi_use_supported,
'min_value': str(min_value) if min_value is not None else None,
'max_value': str(max_value) if max_value is not None else None,
'info_data': info_data or {},
})
def get_fees(event, request, total, invoice_address, payments, positions): def get_fees(event, request, total, invoice_address, payments, positions):

View File

@@ -10,18 +10,41 @@
<i class="fa fa-shopping-cart" aria-hidden="true"></i> <i class="fa fa-shopping-cart" aria-hidden="true"></i>
<strong>{% trans "Your cart" %}</strong> <strong>{% trans "Your cart" %}</strong>
</span> </span>
<strong id="cart-deadline-short" data-expires="{{ cart.first_expiry|date:"Y-m-d H:i:sO" }}" aria-hidden="true"> {% if cart.positions %}
{% if cart.minutes_left > 0 or cart.seconds_left > 0 %} <strong id="cart-deadline-short" data-expires="{{ cart.first_expiry|date:"Y-m-d H:i:sO" }}" aria-hidden="true">
{{ cart.minutes_left|stringformat:"02d" }}:{{ cart.seconds_left|stringformat:"02d" }} {% if cart.minutes_left > 0 or cart.seconds_left > 0 %}
{% else %} {{ cart.minutes_left|stringformat:"02d" }}:{{ cart.seconds_left|stringformat:"02d" }}
{% trans "Cart expired" %} {% else %}
{% endif %} {% trans "Cart expired" %}
</strong> {% endif %}
</strong>
{% endif %}
</h2> </h2>
</summary> </summary>
<div> <div>
<div class="panel-body"> <div class="panel-body">
{% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=True %} {% if cart.positions %}
{% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=True %}
{% endif %}
{% if cart.current_selected_payments %}
<p>{% trans "You already selected the following payment methods:" %}</p>
<div class="list-group">
{% for p in cart.current_selected_payments %}
<div class="list-group-item">
<div class="row">
<div class="col-xs-9">
{{ p.provider_name }}
</div>
<div class="col-xs-3 text-right">
{% if p.payment_amount %}
{{ p.payment_amount|money:request.event.currency }}
{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="checkout-button-row"> <div class="checkout-button-row">
<form class="checkout-button-primary" method="get" action="{% eventurl request.event "presale:event.checkout.start" cart_namespace=cart_namespace %}"> <form class="checkout-button-primary" method="get" action="{% eventurl request.event "presale:event.checkout.start" cart_namespace=cart_namespace %}">
<p><button class="btn btn-primary btn-lg" type="submit"{% if has_addon_choices or cart.total == 0 %} aria-label="{% trans "Continue with order process" %}"{% endif %}> <p><button class="btn btn-primary btn-lg" type="submit"{% if has_addon_choices or cart.total == 0 %} aria-label="{% trans "Continue with order process" %}"{% endif %}>

View File

@@ -251,7 +251,8 @@ class CartMixin:
'seconds_left': seconds_left, 'seconds_left': seconds_left,
'first_expiry': first_expiry, 'first_expiry': first_expiry,
'is_ordered': bool(order), 'is_ordered': bool(order),
'itemcount': sum(c.count for c in positions if not c.addon_to) 'itemcount': sum(c.count for c in positions if not c.addon_to),
'current_selected_payments': [p for p in self.current_selected_payments(total) if p.get('multi_use_supported')]
} }
def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False): def current_selected_payments(self, total, warn=False, total_includes_payment_fees=False):

View File

@@ -42,6 +42,7 @@ from urllib.parse import quote
from django.conf import settings from django.conf import settings
from django.contrib import messages from django.contrib import messages
from django.core.cache import caches from django.core.cache import caches
from django.core.exceptions import ValidationError
from django.db.models import Q from django.db.models import Q
from django.http import FileResponse, Http404, JsonResponse from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, render from django.shortcuts import get_object_or_404, render
@@ -57,7 +58,7 @@ from django.views.generic import TemplateView, View
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.models import ( from pretix.base.models import (
CartPosition, InvoiceAddress, QuestionAnswer, SubEvent, Voucher, CartPosition, GiftCard, InvoiceAddress, QuestionAnswer, SubEvent, Voucher,
) )
from pretix.base.services.cart import ( from pretix.base.services.cart import (
CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages, CartError, add_items_to_cart, apply_voucher, clear_cart, error_messages,
@@ -438,8 +439,48 @@ class CartApplyVoucher(EventViewMixin, CartActionMixin, AsyncAction, View):
return _('We applied the voucher to as many products in your cart as we could.') return _('We applied the voucher to as many products in your cart as we could.')
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
from pretix.base.payment import GiftCardPayment
if 'voucher' in request.POST: if 'voucher' in request.POST:
return self.do(self.request.event.id, request.POST.get('voucher'), get_or_create_cart_id(self.request), code = request.POST.get('voucher').strip()
if not self.request.event.vouchers.filter(code__iexact=code):
try:
gc = self.request.event.organizer.accepted_gift_cards.get(secret=code)
gcp = GiftCardPayment(self.request.event)
if not gcp.is_enabled or not gcp.is_allowed(self.request, Decimal("1.00")):
raise ValidationError(error_messages['voucher_invalid'])
else:
cs = cart_session(request)
gcp._add_giftcard_to_cart(cs, gc)
messages.success(
request,
_("The gift card has been saved to your cart. Please continue your checkout.")
)
if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({
'ready': True,
'success': True,
'redirect': self.get_success_url(),
'message': str(
_("The gift card has been saved to your cart. Please continue your checkout.")
)
})
return redirect_to_url(self.get_success_url())
except GiftCard.DoesNotExist:
pass
except ValidationError as e:
messages.error(self.request, str(e.message))
if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({
'ready': True,
'success': False,
'redirect': self.get_success_url(),
'message': str(e.message)
})
return redirect_to_url(self.get_error_url())
return self.do(self.request.event.id, code, get_or_create_cart_id(self.request),
translation.get_language(), request.sales_channel.identifier, translation.get_language(), request.sales_channel.identifier,
time_machine_now(default=None)) time_machine_now(default=None))
else: else:
@@ -631,6 +672,8 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView
return context return context
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
from pretix.base.payment import GiftCardPayment
err = None err = None
v = request.GET.get('voucher') v = request.GET.get('voucher')
@@ -653,10 +696,24 @@ class RedeemView(NoSearchIndexViewMixin, EventViewMixin, CartMixin, TemplateView
if v_avail < 1 and not err: if v_avail < 1 and not err:
err = error_messages['voucher_redeemed_cart'] % self.request.event.settings.reservation_time err = error_messages['voucher_redeemed_cart'] % self.request.event.settings.reservation_time
except Voucher.DoesNotExist: except Voucher.DoesNotExist:
if self.request.event.organizer.accepted_gift_cards.filter(secret__iexact=request.GET.get("voucher")).exists(): try:
err = error_messages['gift_card'] gc = self.request.event.organizer.accepted_gift_cards.get(secret=v.strip())
else: gcp = GiftCardPayment(self.request.event)
if not gcp.is_enabled or not gcp.is_allowed(self.request, Decimal("1.00")):
err = error_messages['voucher_invalid']
else:
cs = cart_session(request)
gcp._add_giftcard_to_cart(cs, gc)
messages.success(
request,
_("The gift card has been saved to your cart. Please now select the products "
"you want to purchase.")
)
return redirect_to_url(self.get_next_url())
except GiftCard.DoesNotExist:
err = error_messages['voucher_invalid'] err = error_messages['voucher_invalid']
except ValidationError as e:
err = str(e.message)
else: else:
context = {} context = {}
context['cart'] = self.get_cart() context['cart'] = self.get_cart()

View File

@@ -632,7 +632,7 @@ class EventIndex(EventViewMixin, EventListMixin, CartMixin, TemplateView):
context['subevent_list_cache_key'] = self._subevent_list_cachekey() context['subevent_list_cache_key'] = self._subevent_list_cachekey()
context['show_cart'] = ( context['show_cart'] = (
context['cart']['positions'] and ( (context['cart']['positions'] or context['cart'].get('current_selected_payments')) and (
self.request.event.has_subevents or self.request.event.presale_is_running self.request.event.has_subevents or self.request.event.presale_is_running
) )
) )

View File

@@ -2306,6 +2306,25 @@ class CartTest(CartTestMixin, TestCase):
assert cp1.voucher is None assert cp1.voucher is None
assert cp2.voucher is None assert cp2.voucher is None
def test_voucher_apply_is_a_giftcard(self):
with scopes_disabled():
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.ticket,
price=23, listed_price=23, price_after_voucher=23, expires=now() + timedelta(minutes=10)
)
CartPosition.objects.create(
event=self.event, cart_id=self.session_key, item=self.shirt, variation=self.shirt_blue,
price=8, expires=now() + timedelta(minutes=10),
)
gc = self.orga.issued_gift_cards.create(secret="GIFTCARD", currency=self.event.currency)
gc.transactions.create(value=Decimal("12.24"), acceptor=self.orga)
html = self.client.post('/%s/%s/cart/voucher' % (self.orga.slug, self.event.slug), {
'voucher': 'GIFTCARD',
}, follow=True)
assert "alert-success" in html.rendered_content
assert "€12.24" in html.rendered_content
def test_discount(self): def test_discount(self):
with scopes_disabled(): with scopes_disabled():
Discount.objects.create(event=self.event, condition_min_count=2, benefit_discount_matching_percent=20, Discount.objects.create(event=self.event, condition_min_count=2, benefit_discount_matching_percent=20,

View File

@@ -55,6 +55,7 @@ from pretix.base.models import (
) )
from pretix.base.models.items import SubEventItem, SubEventItemVariation from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.reldate import RelativeDate, RelativeDateWrapper from pretix.base.reldate import RelativeDate, RelativeDateWrapper
from pretix.testutils.sessions import get_cart_session_key
class EventTestMixin: class EventTestMixin:
@@ -1003,6 +1004,25 @@ class VoucherRedeemItemDisplayTest(EventTestMixin, SoupTest):
assert 'name="variation_%d_%d' % (self.item.pk, var1.pk) not in html.rendered_content assert 'name="variation_%d_%d' % (self.item.pk, var1.pk) not in html.rendered_content
assert 'name="variation_%d_%d' % (self.item.pk, var2.pk) not in html.rendered_content assert 'name="variation_%d_%d' % (self.item.pk, var2.pk) not in html.rendered_content
def test_voucher_is_a_gift_card(self):
gc = self.orga.issued_gift_cards.create(secret="GIFTCARD", currency=self.event.currency)
gc.transactions.create(value=Decimal("12.00"), acceptor=self.orga)
html = self.client.get('/%s/%s/redeem?voucher=%s' % (self.orga.slug, self.event.slug, 'GIFTCARD'), follow=True)
assert "alert-success" in html.rendered_content
assert "€12.00" in html.rendered_content
payments = self.client.session['carts'][get_cart_session_key(self.client, self.event)]["payments"]
assert payments[0]["info_data"]["gift_card_secret"] == "GIFTCARD"
def test_voucher_is_a_gift_card_but_invalid(self):
gc = self.orga.issued_gift_cards.create(secret="GIFTCARD", currency=self.event.currency, expires=now() - datetime.timedelta(days=1))
gc.transactions.create(value=Decimal("12.00"), acceptor=self.orga)
html = self.client.get('/%s/%s/redeem?voucher=%s' % (self.orga.slug, self.event.slug, 'GIFTCARD'), follow=True)
assert "alert-danger" in html.rendered_content
assert "This gift card is no longer valid" in html.rendered_content
class WaitingListTest(EventTestMixin, SoupTest): class WaitingListTest(EventTestMixin, SoupTest):
@scopes_disabled() @scopes_disabled()