From e630858a35edda88f517082c09b26d52018bd22a Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 6 Mar 2015 22:20:04 +0100 Subject: [PATCH 1/6] Allow payment providers to define custom form fields / templates --- src/pretix/base/payment.py | 41 ++++++++++++++++++- .../pretixcontrol/event/payment.html | 2 + src/pretix/plugins/banktransfer/payment.py | 7 ++++ .../banktransfer/checkout_payment_form.html | 10 +++++ src/pretix/plugins/stripe/payment.py | 7 ++++ .../pretixpresale/event/checkout_payment.html | 1 + .../event/checkout_payment_form_default.html | 2 + src/pretix/presale/views/checkout.py | 1 + 8 files changed, 70 insertions(+), 1 deletion(-) create mode 100644 src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html create mode 100644 src/pretix/presale/templates/pretixpresale/event/checkout_payment_form_default.html diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index be9af95a13..d19901489d 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -1,5 +1,9 @@ from decimal import Decimal +from django.forms import Form +from django.template import Context +from django.template.loader import get_template + from pretix.base.settings import SettingsSandbox @@ -50,6 +54,41 @@ class BasePaymentProvider: def settings_form_fields(self) -> dict: """ A dictionary. The keys should be (unprefixed) EventSetting keys, - the values should be corresponding django form fields + the values should be corresponding django form fields. + + We suggest returning a collections.OrderedDict object instead of a dict. """ raise NotImplementedError() + + @property + def checkout_form_fields(self) -> dict: + """ + A dictionary. The keys should be unprefixed field names, + the values should be corresponding django form fields. + + We suggest returning a collections.OrderedDict object instead of a dict. + """ + # TODO: Proper handling of required=True fields in HTML + return {} + + def checkout_form(self, request) -> Form: + """ + Returns the Form object of the form that should be displayed when the + user selects this provider as his payment method. + """ + form = Form( + data=(request.POST if request.method == 'POST' else None), + prefix='payment_%s' % self.identifier + ) + form.fields = self.checkout_form_fields + return form + + def checkout_form_render(self, request) -> str: + """ + Returns the HTML of the form that should be displayed when the user + selects this provider as his payment method. + """ + form = self.checkout_form(request) + template = get_template('pretixpresale/event/checkout_payment_form_default.html') + ctx = Context({'request': request, 'form': form}) + return template.render(ctx) diff --git a/src/pretix/control/templates/pretixcontrol/event/payment.html b/src/pretix/control/templates/pretixcontrol/event/payment.html index c965ace7fe..dec5bf91bd 100644 --- a/src/pretix/control/templates/pretixcontrol/event/payment.html +++ b/src/pretix/control/templates/pretixcontrol/event/payment.html @@ -24,6 +24,8 @@ {% bootstrap_form provider.form layout='horizontal' %} + {% empty %} + {% trans "There are no payment providers available. Please go to the plugin settings and activate one or more payment plugins." %} {% endfor %}
diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index 06d13fe044..5206b5eca9 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -1,4 +1,6 @@ from collections import OrderedDict +from django.template import Context +from django.template.loader import get_template from django.utils.translation import ugettext_lazy as _ from django import forms @@ -16,3 +18,8 @@ class BankTransfer(BasePaymentProvider): required=False )) ]) + + def checkout_form_render(self, request) -> str: + template = get_template('pretixplugins/banktransfer/checkout_payment_form.html') + ctx = Context({'request': request, 'event': self.event, 'settings': self.settings}) + return template.render(ctx) diff --git a/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html new file mode 100644 index 0000000000..9cdfa83da8 --- /dev/null +++ b/src/pretix/plugins/banktransfer/templates/pretixplugins/banktransfer/checkout_payment_form.html @@ -0,0 +1,10 @@ +{% load i18n %} + +

{% blocktrans trimmed %} + After completing your purchase, we will ask you to transfer the money to the following + bank account, using a personal reference code. +{% endblocktrans %}

+ +
+ {{ settings.bank_details|linebreaksbr }} +
\ No newline at end of file diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index da996d5bc9..a8ca31c05a 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -10,3 +10,10 @@ class Stripe(BasePaymentProvider): verbose_name = _('Credit Card via Stripe') settings_form_fields = OrderedDict([ ]) + checkout_form_fields = OrderedDict([ + ('cc_number', + forms.CharField( + label=_('Credit card number'), + required=False + )) + ]) diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index 9a51a68e42..4d3a5952fc 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -23,6 +23,7 @@
+ {{ p.form }}
diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment_form_default.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment_form_default.html new file mode 100644 index 0000000000..c087bce041 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment_form_default.html @@ -0,0 +1,2 @@ +{% load bootstrap3 %} +{% bootstrap_form form layout='horizontal' %} diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index 4cc8f26fa8..f45a38d99a 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -218,6 +218,7 @@ class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, providers.append({ 'provider': provider, 'fee': fee, + 'form': provider.checkout_form_render(self.request), }) return providers From e57b23e6169484043e7385393858daf70fd54cab Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 6 Mar 2015 23:35:48 +0100 Subject: [PATCH 2/6] Allow payment providers to process user input --- src/pretix/base/payment.py | 23 +++++++++++++++++++ src/pretix/plugins/banktransfer/payment.py | 3 +++ .../pretixpresale/event/checkout_payment.html | 3 ++- src/pretix/presale/views/checkout.py | 22 +++++++++++++++--- 4 files changed, 47 insertions(+), 4 deletions(-) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index d19901489d..a3423be429 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -92,3 +92,26 @@ class BasePaymentProvider: template = get_template('pretixpresale/event/checkout_payment_form_default.html') ctx = Context({'request': request, 'form': form}) return template.render(ctx) + + def checkout_prepare(self, request, total): + """ + Will be called if the user selects this provider as his payment method. + If the payment provider provides a form to the user to enter payment data, + this method should at least store the user's input into his session. + + It should return True or False, depending of the validity of the user's input, + if the frontend should continue with default behaviour, or a custom HTTP response + (for example, a redirect), if you need special behaviour. + + On errors, it should use Django's message framework to display an error message + to the user (or the normal form validation error messages). + + :param total: The total price of the order, including the payment method fee. + """ + form = self.checkout_form(request) + if form.is_valid(): + for k, v in form.cleaned_data.items(): + request.session['payment_%s_%s' % (self.identifier, k)] = v + return True + else: + return False diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index 5206b5eca9..facb865839 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -23,3 +23,6 @@ class BankTransfer(BasePaymentProvider): template = get_template('pretixplugins/banktransfer/checkout_payment_form.html') ctx = Context({'request': request, 'event': self.event, 'settings': self.settings}) return template.render(ctx) + + def checkout_prepare(self, request, total): + return True diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index 4d3a5952fc..45a1553ff4 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -21,7 +21,8 @@ -
+
{{ p.form }}
diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index f45a38d99a..e396981725 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -6,6 +6,7 @@ from django.shortcuts import redirect from django.utils.functional import cached_property from django.views.generic import View, TemplateView from django.utils.translation import ugettext_lazy as _ +from django.http import HttpResponse from pretix.base.models import CartPosition, Question, QuestionAnswer from pretix.base.signals import register_payment_providers @@ -204,17 +205,20 @@ class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, }) @cached_property - def provider_forms(self): - total = CartPosition.objects.current.filter( + def _total_order_value(self): + return CartPosition.objects.current.filter( Q(user=self.request.user) & Q(event=self.request.event) ).aggregate(sum=Sum('price'))['sum'] + + @cached_property + def provider_forms(self): providers = [] responses = register_payment_providers.send(self.request.event) for receiver, response in responses: provider = response(self.request.event) if not provider.is_enabled: continue - fee = provider.calculate_fee(total) + fee = provider.calculate_fee(self._total_order_value) providers.append({ 'provider': provider, 'fee': fee, @@ -222,6 +226,18 @@ class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, }) return providers + def post(self, request, *args, **kwargs): + for p in self.provider_forms: + if p['provider'].identifier == request.POST.get('payment', ''): + total = self._total_order_value + p['provider'].calculate_fee(self._total_order_value) + resp = p['provider'].checkout_prepare(request, total) + if isinstance(resp, HttpResponse): + return resp + elif resp is True: + return redirect(self.get_success_url()) + else: + return self.get(request, *args, **kwargs) + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['providers'] = self.provider_forms From 2acc653807602eb9d120d56a07d75f87f0befab3 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Fri, 6 Mar 2015 23:57:09 +0100 Subject: [PATCH 3/6] Basic confirmation page --- .../static/pretixpresale/less/event.less | 3 ++ .../pretixpresale/event/checkout_confirm.html | 46 +++++++++++++++++++ .../pretixpresale/event/fragment_cart.html | 12 +++++ src/pretix/presale/urls.py | 2 + src/pretix/presale/views/__init__.py | 14 +++++- src/pretix/presale/views/checkout.py | 16 +++++-- 6 files changed, 89 insertions(+), 4 deletions(-) create mode 100644 src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html diff --git a/src/pretix/presale/static/pretixpresale/less/event.less b/src/pretix/presale/static/pretixpresale/less/event.less index a6560c3293..19f97f1f31 100644 --- a/src/pretix/presale/static/pretixpresale/less/event.less +++ b/src/pretix/presale/static/pretixpresale/less/event.less @@ -44,6 +44,9 @@ border-top: 1px solid @table-border-color; } } +.panel-primary .panel-heading a { + color: white; +} .checkout-button-row { padding: 15px 0; } diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html new file mode 100644 index 0000000000..0e78521d65 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -0,0 +1,46 @@ +{% extends "pretixpresale/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Confirm order" %}{% endblock %} +{% block content %} +

{% trans "Confirm order" %}

+

{% trans "Please review the details below and confirm your order." %}

+
+ {% csrf_token %} +
+
+ +

+ {% trans "Your cart" %} +

+
+
+ {% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=False %} +
+
+ {% if cart.minutes_left > 0 %} + {% blocktrans trimmed with minutes=cart.minutes_left %} + The items in your cart are reserved for you for {{ minutes }} minutes. + {% endblocktrans %} + {% else %} + {% trans "The items in your cart are no longer reserved for you." %} + {% endif %} +
+
+
+
+
+ {# TODO: Question answers #} + {# TODO: Payment method #} +
+
+ +
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index d894589785..7715ff46a1 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -52,6 +52,18 @@
{% endfor %} +{% if cart.payment_fee %} + {# TODO: Tax rate? #} +
+
+ {% trans "Payment method fee" %} +
+
+ {{ event.currency }} {{ cart.payment_fee|floatformat:2 }} +
+
+
+{% endif %}
{% trans "Total" %} diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 761943d2f0..4eeb6236a6 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -16,6 +16,8 @@ urlpatterns = patterns( url(r'^checkout$', pretix.presale.views.checkout.CheckoutStart.as_view(), name='event.checkout.start'), url(r'^checkout/payment$', pretix.presale.views.checkout.PaymentDetails.as_view(), name='event.checkout.payment'), + url(r'^checkout/confirm$', pretix.presale.views.checkout.OrderConfirm.as_view(), + name='event.checkout.confirm'), url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'), ) )), diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 5066000531..25157302d8 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -8,6 +8,7 @@ from django.db.models import Q from django.utils.timezone import now from pretix.base.models import CartPosition +from pretix.base.signals import register_payment_providers class EventLoginRequiredMixin: @@ -59,10 +60,21 @@ class CartDisplayMixin: group.total = group.count * group.price positions.append(group) + total = sum(p.total for p in positions) + + payment_fee = 0 + if 'payment' in self.request.session: + responses = register_payment_providers.send(self.request.event) + for receiver, response in responses: + provider = response(self.request.event) + if provider.identifier == self.request.session['payment']: + payment_fee = provider.calculate_fee(total) + return { 'positions': positions, 'raw': cartpos, - 'total': sum(p.total for p in positions), + 'total': total + payment_fee, + 'payment_fee': payment_fee, 'minutes_left': ( max(min(p.expires for p in positions) - now(), timedelta()).seconds // 60 if positions else 0 diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index e396981725..c18d3e2dc5 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -76,7 +76,7 @@ class QuestionsForm(forms.Form): self.fields['question_%s' % q.identity] = field -class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): +class CheckoutStart(EventViewMixin, EventLoginRequiredMixin, TemplateView): template_name = "pretixpresale/event/checkout_questions.html" def get_success_url(self): @@ -183,11 +183,11 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T return ctx -class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): +class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, TemplateView): template_name = "pretixpresale/event/checkout_payment.html" def get_success_url(self): - return reverse('presale:event.index', kwargs={ + return reverse('presale:event.checkout.confirm', kwargs={ 'event': self.request.event.slug, 'organizer': self.request.event.organizer.slug, }) @@ -229,6 +229,7 @@ class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, def post(self, request, *args, **kwargs): for p in self.provider_forms: if p['provider'].identifier == request.POST.get('payment', ''): + request.session['payment'] = p['provider'].identifier total = self._total_order_value + p['provider'].calculate_fee(self._total_order_value) resp = p['provider'].checkout_prepare(request, total) if isinstance(resp, HttpResponse): @@ -242,3 +243,12 @@ class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, ctx = super().get_context_data(**kwargs) ctx['providers'] = self.provider_forms return ctx + + +class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): + template_name = "pretixpresale/event/checkout_confirm.html" + + def get_context_data(self, **kwargs): + ctx = super().get_context_data(**kwargs) + ctx['cart'] = self.get_cart() + return ctx From 3bacfdcb9fff904f153bfdbbe5cb928ac150d839 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sat, 7 Mar 2015 13:32:09 +0100 Subject: [PATCH 4/6] Enhance payment method validation --- src/pretix/base/payment.py | 15 +++- src/pretix/plugins/banktransfer/payment.py | 3 + src/pretix/plugins/stripe/payment.py | 3 + .../pretixpresale/event/checkout_confirm.html | 16 +++- .../pretixpresale/event/checkout_payment.html | 3 +- src/pretix/presale/views/__init__.py | 17 ++++ src/pretix/presale/views/checkout.py | 84 +++++++++++++++---- 7 files changed, 121 insertions(+), 20 deletions(-) diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index a3423be429..090e8d1c3b 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -78,7 +78,12 @@ class BasePaymentProvider: """ form = Form( data=(request.POST if request.method == 'POST' else None), - prefix='payment_%s' % self.identifier + prefix='payment_%s' % self.identifier, + initial={ + k.replace('payment_%s_' % self.identifier, ''): v + for k, v in request.session.items() + if k.startswith('payment_%s_' % self.identifier) + } ) form.fields = self.checkout_form_fields return form @@ -115,3 +120,11 @@ class BasePaymentProvider: return True else: return False + + def checkout_is_valid_session(self, request): + """ + This is called at the time the user tries to place the order. It should return + True, if the user's session is valid and all data your payment provider requires + in future steps is present. + """ + raise NotImplementedError() diff --git a/src/pretix/plugins/banktransfer/payment.py b/src/pretix/plugins/banktransfer/payment.py index facb865839..b7fc76b07e 100644 --- a/src/pretix/plugins/banktransfer/payment.py +++ b/src/pretix/plugins/banktransfer/payment.py @@ -26,3 +26,6 @@ class BankTransfer(BasePaymentProvider): def checkout_prepare(self, request, total): return True + + def checkout_is_valid_session(self, request): + return True diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index a8ca31c05a..245f79d358 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -17,3 +17,6 @@ class Stripe(BasePaymentProvider): required=False )) ]) + + def checkout_is_valid_session(self, request): + return False diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html index 0e78521d65..7ac95dc2c7 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -33,7 +33,21 @@
{# TODO: Question answers #} - {# TODO: Payment method #} +
+
+
+ +

+ {% trans "Payment" %} +

+
+
+ {{ payment }} +
+
+
+ class="panel-collapse collapse {% if selected == p.provider.identifier %}in{% endif %}">
{{ p.form }}
diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 25157302d8..6beaa45dfe 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -5,6 +5,7 @@ from django.contrib.auth.views import redirect_to_login from django.core.urlresolvers import reverse from django.db.models import Q +from django.utils.functional import cached_property from django.utils.timezone import now from pretix.base.models import CartPosition @@ -35,6 +36,22 @@ class EventLoginRequiredMixin: class CartDisplayMixin: + @cached_property + def cartpos(self): + """ + A list of this users cart position + """ + return list(CartPosition.objects.current.filter( + Q(user=self.request.user) & Q(event=self.request.event) + ).order_by( + 'item', 'variation' + ).select_related( + 'item', 'variation' + ).prefetch_related( + 'variation__values', 'variation__values__prop', + 'item__questions', 'answers' + )) + def get_cart(self): cartpos = CartPosition.objects.current.filter( Q(user=self.request.user) & Q(event=self.request.event) diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index c18d3e2dc5..d238e958fb 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -76,7 +76,7 @@ class QuestionsForm(forms.Form): self.fields['question_%s' % q.identity] = field -class CheckoutStart(EventViewMixin, EventLoginRequiredMixin, TemplateView): +class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): template_name = "pretixpresale/event/checkout_questions.html" def get_success_url(self): @@ -115,22 +115,6 @@ class CheckoutStart(EventViewMixin, EventLoginRequiredMixin, TemplateView): formlist.append(form) return formlist - @cached_property - def cartpos(self): - """ - A list of this users cart position - """ - return list(CartPosition.objects.current.filter( - Q(user=self.request.user) & Q(event=self.request.event) - ).order_by( - 'item', 'variation' - ).select_related( - 'item', 'variation' - ).prefetch_related( - 'variation__values', 'variation__values__prop', - 'item__questions', 'answers' - )) - def post(self, *args, **kwargs): failed = False for form in self.forms: @@ -242,13 +226,79 @@ class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, TemplateView): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['providers'] = self.provider_forms + ctx['selected'] = self.request.POST.get('payment', self.request.session.get('payment', '')) return ctx class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): template_name = "pretixpresale/event/checkout_confirm.html" + def get_success_url(self): + return reverse('presale:event.checkout.success', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + }) + + def get_url(self): + return reverse('presale:event.checkout.confirm', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + }) + + def get_previous_url(self): + return reverse('presale:event.checkout.payment', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + }) + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['cart'] = self.get_cart() return ctx + + @cached_property + def payment_provider(self): + responses = register_payment_providers.send(self.request.event) + for receiver, response in responses: + provider = response(self.request.event) + if provider.identifier == self.request.session['payment']: + return provider + + def check_process(self, request): + if not self.payment_provider: + messages.error(request, _('The payment information you entered was incomplete.')) + return redirect(self.get_previous_url()) + if not self.payment_provider.checkout_is_valid_session(request): + messages.error(request, _('The payment information you entered was incomplete.')) + return redirect(self.get_previous_url()) + if len(self.cart_items) == 0: + messages.warning(request, _('Your cart is empty.')) + return redirect(reverse('presale:event.index', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + })) + for cp in self.cart_items: + answ = { + aw.question_id: aw.answer for aw in cp.answers.all() + } + for q in cp.item.questions.all(): + if q.required and q.identity not in answ: + messages.warning(request, _('Please fill in answers to all required questions.')) + return redirect(reverse('presale:event.checkout.start', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + })) + + def get(self, request, *args, **kwargs): + self.request = request + check = self.check_process(request) + if check: + return check + return super().get(request, *args, **kwargs) + + def post(self, request, *args, **kwargs): + self.request = request + check = self.check_process(request) + if check: + return check + return super().post(request, *args, **kwargs) From 62b82bc8525e9c6ad7648653f569d77e734db832 Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sat, 7 Mar 2015 13:39:38 +0100 Subject: [PATCH 5/6] Reduce duplicate code --- src/pretix/presale/views/checkout.py | 87 +++++++++------------------- 1 file changed, 27 insertions(+), 60 deletions(-) diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index d238e958fb..0fa792d3fa 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -76,27 +76,36 @@ class QuestionsForm(forms.Form): self.fields['question_%s' % q.identity] = field -class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): - template_name = "pretixpresale/event/checkout_questions.html" +class CheckoutView(TemplateView): - def get_success_url(self): + def get_payment_url(self): return reverse('presale:event.checkout.payment', kwargs={ 'event': self.request.event.slug, 'organizer': self.request.event.organizer.slug, }) - def get_url(self): + def get_confirm_url(self): + return reverse('presale:event.checkout.confirm', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + }) + + def get_questions_url(self): return reverse('presale:event.checkout.start', kwargs={ 'event': self.request.event.slug, 'organizer': self.request.event.organizer.slug, }) - def get_previous_url(self): + def get_index_url(self): return reverse('presale:event.index', kwargs={ 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, + 'organizer': self.request.event.organizer.slug }) + +class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView): + template_name = "pretixpresale/event/checkout_questions.html" + @cached_property def forms(self): """ @@ -147,17 +156,17 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T messages.error(self.request, _("We had difficulties processing your input. Please review the errors below.")) return self.get(*args, **kwargs) - return redirect(self.get_success_url()) + return redirect(self.get_payment_url()) def get(self, *args, **kwargs): if not self.cartpos: messages.error(self.request, _("Your cart is empty")) - return redirect(self.get_previous_url()) + return redirect(self.get_index_url()) if not self.forms: # Nothing to do here - return redirect(self.get_success_url()) + return redirect(self.get_payment_url()) return super().get(*args, **kwargs) @@ -167,27 +176,9 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T return ctx -class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, TemplateView): +class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, CheckoutView): template_name = "pretixpresale/event/checkout_payment.html" - def get_success_url(self): - return reverse('presale:event.checkout.confirm', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - }) - - def get_url(self): - return reverse('presale:event.checkout.payment', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - }) - - def get_previous_url(self): - return reverse('presale:event.checkout.start', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - }) - @cached_property def _total_order_value(self): return CartPosition.objects.current.filter( @@ -219,7 +210,7 @@ class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, TemplateView): if isinstance(resp, HttpResponse): return resp elif resp is True: - return redirect(self.get_success_url()) + return redirect(self.get_confirm_url()) else: return self.get(request, *args, **kwargs) @@ -230,27 +221,9 @@ class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, TemplateView): return ctx -class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): +class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView): template_name = "pretixpresale/event/checkout_confirm.html" - def get_success_url(self): - return reverse('presale:event.checkout.success', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - }) - - def get_url(self): - return reverse('presale:event.checkout.confirm', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - }) - - def get_previous_url(self): - return reverse('presale:event.checkout.payment', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - }) - def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['cart'] = self.get_cart() @@ -267,27 +240,21 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Te def check_process(self, request): if not self.payment_provider: messages.error(request, _('The payment information you entered was incomplete.')) - return redirect(self.get_previous_url()) + return redirect(self.get_payment_url()) if not self.payment_provider.checkout_is_valid_session(request): messages.error(request, _('The payment information you entered was incomplete.')) - return redirect(self.get_previous_url()) - if len(self.cart_items) == 0: + return redirect(self.get_payment_url()) + if len(self.cartpos) == 0: messages.warning(request, _('Your cart is empty.')) - return redirect(reverse('presale:event.index', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - })) - for cp in self.cart_items: + return redirect(self.get_index_url()) + for cp in self.cartpos: answ = { aw.question_id: aw.answer for aw in cp.answers.all() } for q in cp.item.questions.all(): if q.required and q.identity not in answ: messages.warning(request, _('Please fill in answers to all required questions.')) - return redirect(reverse('presale:event.checkout.start', kwargs={ - 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug, - })) + return redirect(self.get_questions_url()) def get(self, request, *args, **kwargs): self.request = request From a08b43ad45f039cdc5e8581c25b38830315b45ef Mon Sep 17 00:00:00 2001 From: Raphael Michel Date: Sun, 8 Mar 2015 11:20:17 +0100 Subject: [PATCH 6/6] Submitting orders --- .../migrations/0015_auto_20150308_0953.py | 24 ++++ .../migrations/0016_auto_20150308_1017.py | 26 ++++ src/pretix/base/models.py | 44 +++++- src/pretix/base/payment.py | 23 +++- src/pretix/base/settings.py | 1 + src/pretix/presale/urls.py | 2 + src/pretix/presale/views/cart.py | 4 +- src/pretix/presale/views/checkout.py | 128 +++++++++++++++++- 8 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 src/pretix/base/migrations/0015_auto_20150308_0953.py create mode 100644 src/pretix/base/migrations/0016_auto_20150308_1017.py diff --git a/src/pretix/base/migrations/0015_auto_20150308_0953.py b/src/pretix/base/migrations/0015_auto_20150308_0953.py new file mode 100644 index 0000000000..993c63e277 --- /dev/null +++ b/src/pretix/base/migrations/0015_auto_20150308_0953.py @@ -0,0 +1,24 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0014_auto_20150305_2310'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='payment_provider', + field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider'), + ), + migrations.AlterField( + model_name='order', + name='datetime', + field=models.DateTimeField(verbose_name='Date'), + ), + ] diff --git a/src/pretix/base/migrations/0016_auto_20150308_1017.py b/src/pretix/base/migrations/0016_auto_20150308_1017.py new file mode 100644 index 0000000000..2967974276 --- /dev/null +++ b/src/pretix/base/migrations/0016_auto_20150308_1017.py @@ -0,0 +1,26 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0015_auto_20150308_0953'), + ] + + operations = [ + migrations.AddField( + model_name='order', + name='code', + field=models.CharField(max_length=16, verbose_name='Order code', default=''), + preserve_default=False, + ), + migrations.AddField( + model_name='order', + name='payment_fee', + field=models.DecimalField(max_digits=10, verbose_name='Payment method fee', decimal_places=2, default=0), + preserve_default=False, + ), + ] diff --git a/src/pretix/base/models.py b/src/pretix/base/models.py index a3c5099585..0524ad8721 100644 --- a/src/pretix/base/models.py +++ b/src/pretix/base/models.py @@ -1,6 +1,7 @@ from itertools import product import copy import uuid +import random import time from django.db import models @@ -1219,8 +1220,9 @@ class Order(Versionable): expiration date: If items run out of capacity, orders which are over their expiration date might be cancelled. - Important: An order holds its total monetary value, as an order is a - piece of 'history' and must not change due to a change in item prices. + An order -- like all objects -- has an ID, which is globally unique, + but also a code, which is shorter and easier to memorize, but only + unique among a single conference. """ STATUS_PENDING = "n" @@ -1234,6 +1236,10 @@ class Order(Versionable): (STATUS_CANCELLED, _("cancelled")), ) + code = models.CharField( + max_length=16, + verbose_name=_("Order code") + ) status = models.CharField( max_length=3, choices=STATUS_CHOICE, @@ -1248,7 +1254,6 @@ class Order(Versionable): verbose_name=_("User") ) datetime = models.DateTimeField( - auto_now_add=True, verbose_name=_("Date") ) expires = models.DateTimeField( @@ -1258,6 +1263,15 @@ class Order(Versionable): verbose_name=_("Payment date"), null=True, blank=True ) + payment_provider = models.CharField( + null=True, blank=True, + max_length=255, + verbose_name=_("Payment provider") + ) + payment_fee = models.DecimalField( + decimal_places=2, max_digits=10, + verbose_name=_("Payment method fee") + ) payment_info = models.TextField( verbose_name=_("Payment information"), null=True, blank=True @@ -1271,6 +1285,30 @@ class Order(Versionable): verbose_name = _("Order") verbose_name_plural = _("Orders") + def str(self): + return self.full_code + + @property + def full_code(self): + """ + A order code which is unique among all events of a single organizer, + built by contatenating the event slug and the order code. + """ + return self.event.slug.upper() + self.code + + def save(self, *args, **kwargs): + if not self.code: + self.assign_code() + super().save(*args, **kwargs) + + def assign_code(self): + charset = list('ABCDEFGHKLMNPQRSTUVWXYZ23456789') + while True: + code = "".join([random.choice(charset) for i in range(5)]) + if not Order.objects.filter(event=self.event, code=code).exists(): + self.code = code + return + class QuestionAnswer(Versionable): """ diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 090e8d1c3b..48f9e0e9e7 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -98,15 +98,15 @@ class BasePaymentProvider: ctx = Context({'request': request, 'form': form}) return template.render(ctx) - def checkout_prepare(self, request, total): + def checkout_prepare(self, request, total) -> "bool|HttpResponse": """ Will be called if the user selects this provider as his payment method. If the payment provider provides a form to the user to enter payment data, this method should at least store the user's input into his session. It should return True or False, depending of the validity of the user's input, - if the frontend should continue with default behaviour, or a custom HTTP response - (for example, a redirect), if you need special behaviour. + if the frontend should continue with default behaviour, or a redirect URL, + if you need special behaviour. On errors, it should use Django's message framework to display an error message to the user (or the normal form validation error messages). @@ -121,10 +121,25 @@ class BasePaymentProvider: else: return False - def checkout_is_valid_session(self, request): + def checkout_is_valid_session(self, request) -> bool: """ This is called at the time the user tries to place the order. It should return True, if the user's session is valid and all data your payment provider requires in future steps is present. """ raise NotImplementedError() + + def checkout_perform(self, request, order) -> str: + """ + Will be called if the user submitted his order successfully to initiate the + payment process. + + It should return a custom redirct URL, if you need special behaviour, or None to + continue with default behaviour. + + On errors, it should use Django's message framework to display an error message + to the user (or the normal form validation error messages). + + :param order: The order object + """ + return None diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 156656b877..97019616f9 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -10,6 +10,7 @@ DEFAULTS = { 'max_items_per_order': '10', 'attendee_names_asked': 'True', 'attendee_names_required': 'False', + 'reservation_time': '30', } diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 4eeb6236a6..99de7e299d 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -18,6 +18,8 @@ urlpatterns = patterns( name='event.checkout.payment'), url(r'^checkout/confirm$', pretix.presale.views.checkout.OrderConfirm.as_view(), name='event.checkout.confirm'), + url(r'^order/(?P[^/]+)/$', pretix.presale.views.checkout.OrderConfirm.as_view(), + name='event.order'), url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'), ) )), diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 0052d75220..1f531749ef 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -136,7 +136,7 @@ class CartAdd(EventViewMixin, CartActionMixin, View): ).update(expires=now() + timedelta(minutes=30)) # For items that are already expired, we have to delete and re-add them, as they might - # be no longer available. Sorry! + # be no longer available or prices might have changed. Sorry! for cp in CartPosition.objects.current.filter( Q(user=self.request.user) & Q(event=self.request.event) & Q(expires__lte=now())): items = self._re_add_position(items, cp) @@ -220,7 +220,7 @@ class CartAdd(EventViewMixin, CartActionMixin, View): item=item, variation=variation, price=price, - expires=now() + timedelta(minutes=30) + expires=now() + timedelta(minutes=self.request.event.settings.get('reservation_time', as_type=int)) ) except Quota.LockTimeoutException: # Is raised when there are too many threads asking for quota locks and we were diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index 0fa792d3fa..e963b645de 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -1,13 +1,15 @@ +from datetime import timedelta from django.contrib import messages from django.core.urlresolvers import reverse +from django.db import transaction from django.db.models import Q, Sum from django import forms from django.shortcuts import redirect from django.utils.functional import cached_property +from django.utils.timezone import now from django.views.generic import View, TemplateView from django.utils.translation import ugettext_lazy as _ -from django.http import HttpResponse -from pretix.base.models import CartPosition, Question, QuestionAnswer +from pretix.base.models import CartPosition, Question, QuestionAnswer, Quota, Order, OrderPosition from pretix.base.signals import register_payment_providers from pretix.presale.views import EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin @@ -102,6 +104,13 @@ class CheckoutView(TemplateView): 'organizer': self.request.event.organizer.slug }) + def get_order_url(self, order): + return reverse('presale:event.order', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + 'order': order.code, + }) + class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView): template_name = "pretixpresale/event/checkout_questions.html" @@ -207,8 +216,8 @@ class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, CheckoutView): request.session['payment'] = p['provider'].identifier total = self._total_order_value + p['provider'].calculate_fee(self._total_order_value) resp = p['provider'].checkout_prepare(request, total) - if isinstance(resp, HttpResponse): - return resp + if isinstance(resp, str): + return redirect(str) elif resp is True: return redirect(self.get_confirm_url()) else: @@ -224,6 +233,22 @@ class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, CheckoutView): class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView): template_name = "pretixpresale/event/checkout_confirm.html" + error_messages = { + 'unavailable': _('Some of the items you selected were no longer available. ' + 'Please see below for details.'), + 'in_part': _('Some of the items you selected were no longer available in ' + 'the quantity you selected. Please see below for details.'), + 'price_changed': _('The price of some of the items in your cart has changed in the ' + 'meantime. Please see below for details.'), + 'busy': _('We were not able to process your request completely as the ' + 'server was too busy. Please try again.'), + 'max_items': _("You cannot select more than %s items per order"), + } + + def __init__(self, *args, **kwargs): + self.msg_some_unavailable = False + super().__init__(*args, **kwargs) + def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['cart'] = self.get_cart() @@ -263,9 +288,100 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Ch return check return super().get(request, *args, **kwargs) + def error_message(self, msg, important=False): + if not self.msg_some_unavailable or important: + self.msg_some_unavailable = True + messages.error(self.request, msg) + def post(self, request, *args, **kwargs): self.request = request check = self.check_process(request) if check: - return check - return super().post(request, *args, **kwargs) + return + + dt = now() + quotas_locked = set() + + try: + cartpos = self.cartpos + for i, cp in enumerate(cartpos): + quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all()) + if cp.expires < dt: + price = cp.item.check_restrictions() if cp.variation is None else cp.variation.check_restrictions() + if price is False: + self.error_message(self.error_messages['unavailable']) + continue + if len(quotas) == 0: + self.error_message(self.error_messages['unavailable']) + continue + if price != cp.price: + cp = cp.clone() + cartpos[i] = cp + cp.price = price + cp.save() + self.error_message(self.error_messages['price_changed']) + continue + quota_ok = True + for quota in quotas: + # Lock the quota, so no other thread is allowed to perform sales covered by this + # quota while we're doing so. + if quota not in quotas_locked: + quota.lock() + quotas_locked.add(quota) + avail = quota.availability() + if avail[0] != Quota.AVAILABILITY_OK: + # This quota is sold out/currently unavailable, so do not sell this at all + self.error_message(self.error_messages['unavailable']) + quota_ok = False + break + if quota_ok: + cp = cp.clone() + cartpos[i] = cp + cp.expires = now() + timedelta(minutes=self.request.event.settings.get('reservation_time', as_type=int)) + cp.save() + if not self.msg_some_unavailable: # Everything went well + with transaction.atomic(): + total = sum([c.price for c in cartpos]) + payment_fee = self.payment_provider.calculate_fee(total) + total += payment_fee + expires = [dt + timedelta(days=request.event.payment_term_days)] + if request.event.payment_term_last: + expires.append(request.event.payment_term_last) + order = Order.objects.create( + status=Order.STATUS_PENDING, + event=request.event, + user=request.user, + datetime=dt, + expires=min(expires), + total=total, + payment_fee=payment_fee, + payment_provider=self.payment_provider.identifier, + ) + for cp in cartpos: + op = OrderPosition.objects.create( + order=order, item=cp.item, variation=cp.variation, + price=cp.price, attendee_name=cp.attendee_name + ) + for answ in cp.answers.all(): + answ = answ.clone() + answ.orderposition = op + answ.cartposition = None + answ.save() + cp.delete() + messages.success(request, _('Your order has been placed.')) + resp = self.payment_provider.checkout_perform(request, order) + if isinstance(resp, str): + return redirect(str) + else: + return redirect(self.get_order_url(order)) + + except Quota.LockTimeoutException: + # Is raised when there are too many threads asking for quota locks and we were + # unaible to get one + self.error_message(self.error_messages['busy'], important=True) + finally: + # Release the locks. This is important ;) + for quota in quotas_locked: + quota.release() + + return redirect(self.get_confirm_url())