diff --git a/pretix_wallet/models.py b/pretix_wallet/models.py index 339ca66..e53b689 100644 --- a/pretix_wallet/models.py +++ b/pretix_wallet/models.py @@ -1,7 +1,56 @@ +import datetime + from django.db import models -from pretix.base.models import Customer, GiftCard +from pretix.base.models import Customer, GiftCard, MembershipType, Membership +from pretix.base.models.giftcards import gen_giftcard_secret + +VERY_FAR_FUTURE = datetime.date(2099, 12, 12) class CustomerWallet(models.Model): customer = models.OneToOneField(Customer, on_delete=models.CASCADE, related_name='wallet') giftcard = models.OneToOneField(GiftCard, on_delete=models.CASCADE, related_name='wallet') + + @staticmethod + def create_if_non_existent(organizer, customer): + try: + _ = customer.wallet + except CustomerWallet.DoesNotExist: + giftcard = GiftCard.objects.create( + issuer=organizer, + currency="EUR", + conditions=f"Wallet for {customer.name_cached} ({customer.email})", + secret=f"{customer.email.split('@')[0]}-{gen_giftcard_secret(length=organizer.settings.giftcard_length)}") + CustomerWallet.objects.create(customer=customer, giftcard=giftcard) + create_membership_if_not_existant(organizer, customer) + + +def membership_type_name_for_organizer(organizer): + return f"{organizer.name} Wallet" + + +def get_or_create_wallet_membership_type(organizer): + membership_type = organizer.membership_types.filter(name=membership_type_name_for_organizer(organizer)).first() + + if membership_type is not None: + return membership_type + + return MembershipType.objects.create( + name=membership_type_name_for_organizer(organizer), + organizer=organizer, + allow_parallel_usage=True, + transferable=False, + max_usages=None, + ) + + +def create_membership_if_not_existant(organizer, customer): + membership_type = get_or_create_wallet_membership_type(organizer) + if not membership_type.memberships.filter(customer=customer).exists(): + Membership.objects.create( + testmode=False, + customer=customer, + membership_type=membership_type, + date_start=datetime.date.today(), + date_end=VERY_FAR_FUTURE + ) diff --git a/pretix_wallet/payment.py b/pretix_wallet/payment.py index aa2cc05..3ad85f6 100644 --- a/pretix_wallet/payment.py +++ b/pretix_wallet/payment.py @@ -1,10 +1,11 @@ +import ast from _decimal import Decimal from collections import OrderedDict from typing import Dict, Any, Union from django.contrib import messages from django.db import transaction -from django.forms import CharField +from django.forms import CharField, MultipleChoiceField, CheckboxSelectMultiple from django.http import HttpRequest from django.utils.crypto import get_random_string from django.utils.translation import gettext_lazy as _ @@ -13,6 +14,7 @@ from pretix.base.models.customers import CustomerSSOProvider from pretix.base.payment import PaymentException, GiftCardPayment from pretix.base.services.cart import add_payment_to_cart +from pretix.control.forms.item import ItemVariationForm from pretix.helpers import OF_SELF from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.views.cart import cart_session @@ -25,10 +27,10 @@ class WalletPaymentProvider(GiftCardPayment): verbose_name = _("Wallet") public_name = _("Wallet") - def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order=None) -> str: + def payment_form_render(self, request: HttpRequest, total: Decimal, order: Order = None) -> str: return "Wallet payment form" - def checkout_confirm_render(self, request, order: Order=None, info_data: dict=None) -> str: + def checkout_confirm_render(self, request, order: Order = None, info_data: dict = None) -> str: return "Wallet confirm" def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]: @@ -37,7 +39,8 @@ def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[ messages.error(request, _("You do not have a wallet.")) return False if request.customer.wallet.giftcard.value < 0: - messages.error(request, _("Your wallet has a negative balance. Please top it up or use another payment method.")) + messages.error(request, + _("Your wallet has a negative balance. Please top it up or use another payment method.")) return False cart_session(request) add_payment_to_cart( @@ -47,7 +50,8 @@ def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[ info_data=self._get_payment_info_data(request.customer.wallet), ) return True - return self._redirect_user(request, build_absolute_uri(request.event, "presale:event.checkout", kwargs={"step": "payment"})) + return self._redirect_user(request, build_absolute_uri(request.event, "presale:event.checkout", + kwargs={"step": "payment"})) def _redirect_user(self, request: HttpRequest, next_url: str): provider = CustomerSSOProvider.objects.last() @@ -80,24 +84,15 @@ def execute_payment(self, request: HttpRequest, payment: OrderPayment, is_early_ if not gcpk: raise PaymentException("Invalid state, should never occur.") try: - with transaction.atomic(): - try: - gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gcpk) - except GiftCard.DoesNotExist: - raise PaymentException(_("This gift card does not support this currency.")) - if gc.currency != self.event.currency: # noqa - just a safeguard - raise PaymentException(_("This gift card does not support this currency.")) - if not gc.accepted_by(self.event.organizer): - raise PaymentException(_("This gift card is not accepted by this event organizer.")) - - trans = gc.transactions.create( - value=-1 * payment.amount, - order=payment.order, - payment=payment, - acceptor=self.event.organizer, - ) - payment.info_data['transaction_id'] = trans.pk - payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case) + try: + gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gcpk) + except GiftCard.DoesNotExist: + raise PaymentException(_("This gift card does not support this currency.")) + + trans = _wallet_transaction(self.event, payment, gc) + payment.info_data['transaction_id'] = trans.pk + payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case) + except PaymentException as e: payment.fail(info={'error': str(e)}) raise e @@ -108,7 +103,8 @@ def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[ messages.error(request, _("You do not have a wallet.")) return False if request.customer.wallet.giftcard.value < 0: - messages.error(request, _("Your wallet has a negative balance. Please top it up or use another payment method.")) + messages.error(request, + _("Your wallet has a negative balance. Please top it up or use another payment method.")) return False gc = request.customer.wallet.giftcard if gc not in self.event.organizer.accepted_gift_cards: @@ -121,6 +117,39 @@ def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[ @property def settings_form_fields(self): + product_choices = list(map(lambda product: (str(product.pk), product.name), self.event.items.all())) + return OrderedDict(list(super().settings_form_fields.items()) + [ - ('api_key', CharField(label=_("API key"), help_text=_("The API key that the terminal uses to authenticate against the POS api provided by this plugin."))), + ('api_key', CharField(label=_("API key"), help_text=_( + "The API key that the terminal uses to authenticate against the POS api provided by this plugin."))), + ('top_up_products', CharField( + label=_("Products used to charge wallet accounts"), + help_text=_( + "Comma separated list of English product names, Buying these products will charge a users wallet by" + "their price. Remeber, to require the Wallet memberships for these products to force users to login."), + )) + # ('top_up_products', MultipleChoiceField( + # widget=CheckboxSelectMultiple, + # label=_("Products used to charge wallet accounts"), + # required=False, + # initial=[0], + # # initial=ast.literal_eval(self.event.settings.get('payment_wallet_top_up_products')), + # help_text=_( + # "Buying these products will charge a users wallet by their price. Remeber, to require the Wallet memberships for these products to force users to login."), + # choices=product_choices)) ]) + + +def _wallet_transaction(event, payment: OrderPayment, gift_card: GiftCard, sign=-1, amount=None): + with transaction.atomic(): + if gift_card.currency != event.currency: # noqa - just a safeguard + raise PaymentException(_("This gift card does not support this currency.")) + if not gift_card.accepted_by(event.organizer): + raise PaymentException(_("This gift card is not accepted by this event organizer.")) + + return gift_card.transactions.create( + value=sign * (amount if amount else payment.amount), + order=payment.order, + payment=payment, + acceptor=event.organizer, + ) diff --git a/pretix_wallet/signals.py b/pretix_wallet/signals.py index 6f3697e..6526980 100644 --- a/pretix_wallet/signals.py +++ b/pretix_wallet/signals.py @@ -1,9 +1,79 @@ +from django.core.exceptions import ImproperlyConfigured from django.dispatch import receiver -from pretix.base.signals import register_payment_providers +from django.http import Http404 +from django.utils.functional import cached_property +from django.utils.translation import gettext_lazy as _ -from pretix_wallet.payment import WalletPaymentProvider +from pretix.base.payment import PaymentException +from pretix.base.signals import register_payment_providers, order_paid +from pretix.helpers.http import redirect_to_url +from pretix.presale.checkoutflow import BaseCheckoutFlowStep, CartMixin, TemplateFlowStep +from pretix.presale.signals import checkout_flow_steps +from pretix.presale.views.cart import cart_session +from pretix_wallet.models import CustomerWallet + +from pretix_wallet.payment import WalletPaymentProvider, _wallet_transaction @receiver(register_payment_providers, dispatch_uid="payment_wallet") def register_payment_provider(sender, **kwargs): return [WalletPaymentProvider] + + +@receiver(checkout_flow_steps, dispatch_uid="checkout_flow_steps_wallet") +def wallet_checkout_flow_steps(sender, **kwargs): + return MembershipGrantingCheckoutFlowStep + + +@receiver(order_paid, dispatch_uid="payment_wallet_order_paid") +def wallet_order_paid(sender, order, **kwargs): + top_up_positions = list(filter(lambda pos: position_is_top_up_product(sender, pos), order.positions.all())) + if top_up_positions: + CustomerWallet.create_if_non_existent(sender.organizer, order.customer) + try: + top_up_value = sum(map(lambda pos: pos.price, top_up_positions)) + _wallet_transaction(sender, order.payments.last(), order.customer.wallet.giftcard, sign=1, + amount=top_up_value) + except PaymentException as e: + raise e + + +class MembershipGrantingCheckoutFlowStep(CartMixin, BaseCheckoutFlowStep): + icon = 'user-plus' + identifier = 'wallet-membership-granting' + + def label(self): + return _('Creating Wallet') + + @cached_property + def priority(self): + # One less than MembershipStep + return 46 + + def get(self, request): + if request.event.organizer and request.customer: + CustomerWallet.create_if_non_existent(request.event.organizer, request.customer) + return redirect_to_url(self.get_next_url(request)) + else: + raise ImproperlyConfigured('User reached the wallet creation step without signing in.' + 'Have you created customer accounts and required membership' + 'for the top-up product?') + + def is_completed(self, request, warn=False): + if self.request.customer.wallet: + return True + return False + + def is_applicable(self, request): + self.request = request + if not request.event.settings.get("payment_wallet_top_up_products"): + return False + return any(map(lambda pos: position_is_top_up_product(request.event, pos), self.positions)) + + +def position_is_top_up_product(event, position): + if not event.settings.get("payment_wallet_top_up_products"): + return False + top_up_products = event.settings.get("payment_wallet_top_up_products").lower().split(',') + product_name = position.item.name.localize('en').lower() + return product_name in top_up_products diff --git a/pretix_wallet/urls.py b/pretix_wallet/urls.py index 5de103c..301f338 100644 --- a/pretix_wallet/urls.py +++ b/pretix_wallet/urls.py @@ -2,7 +2,7 @@ from pretix.api.urls import event_router from pretix_wallet.views import TransactionListView, ProductViewSet, WalletViewSet, TransactionViewSet, PairingView, \ - RemovePairingView + RemovePairingView, WalletRequiredRedirectView app_name = 'pretix_wallet' @@ -12,6 +12,7 @@ organizer_patterns = [ path('account/wallet/', TransactionListView.as_view(), name='transactions'), + path('account/wallet/login', WalletRequiredRedirectView.as_view(), name='wallet_login'), path('account/wallet/pair//', PairingView.as_view(), name='pair'), path('account/wallet/unpair/', RemovePairingView.as_view(), name='unpair'), ] diff --git a/pretix_wallet/views.py b/pretix_wallet/views.py index 490d025..06267e7 100644 --- a/pretix_wallet/views.py +++ b/pretix_wallet/views.py @@ -1,12 +1,13 @@ from django.contrib import messages from django.http import Http404 from django.shortcuts import redirect +from django.urls import reverse from django.views import View from django.views.generic import ListView, TemplateView from django.utils.translation import gettext_lazy as _ from pretix.base.media import NfcUidMediaType from pretix.base.models import GiftCardTransaction, Item, ReusableMedium, GiftCard -from pretix.base.models.giftcards import gen_giftcard_secret +from pretix.multidomain.urlreverse import build_absolute_uri from pretix.presale.utils import _detect_event from pretix.presale.views.customer import CustomerRequiredMixin from rest_framework.mixins import RetrieveModelMixin, CreateModelMixin @@ -27,15 +28,7 @@ class TerminalAuthMixin: class WalletRequiredMixin: def dispatch(self, request, *args, **kwargs): - try: - _ = request.customer.wallet - except CustomerWallet.DoesNotExist: - giftcard = GiftCard.objects.create( - issuer=request.organizer, - currency="EUR", - conditions=f"Wallet for {request.customer.name_cached} ({request.customer.email})", - secret=f"{request.customer.email.split('@')[0]}-{gen_giftcard_secret(length=request.organizer.settings.giftcard_length)}") - CustomerWallet.objects.create(customer=request.customer, giftcard=giftcard) + CustomerWallet.create_if_non_existent(request.organizer, request.customer) return super().dispatch(request, *args, **kwargs) @@ -115,3 +108,11 @@ def create(self, request, *args, **kwargs): serializer.is_valid(raise_exception=True) self.perform_create(serializer) return Response(WalletSerializer(self.wallet).data, status=HTTP_201_CREATED) + + +class WalletRequiredRedirectView(CustomerRequiredMixin, WalletRequiredMixin, View): + def get(self, *args, **kwargs): + if self.request.GET.get('next'): + return redirect(self.request.GET.get('next')) + else: + return redirect(build_absolute_uri(self.request.organizer, "plugins:pretix_wallet:wallet")) \ No newline at end of file