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 be9af95a13..48f9e0e9e7 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,92 @@ 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, + 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 + + 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) + + 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 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). + + :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 + + 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/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..b7fc76b07e 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,14 @@ 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) + + def checkout_prepare(self, request, total): + return True + + def checkout_is_valid_session(self, request): + return True 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..245f79d358 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -10,3 +10,13 @@ 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 + )) + ]) + + def checkout_is_valid_session(self, request): + return False 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..603027a5c4 --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -0,0 +1,66 @@ +{% 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 #} +
+
+
+ +

+ {% trans "Payment" %} +

+
+
+ {{ payment }} +
+
+
+
+ +
+ +
+
+
+
+{% endblock %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html index b2de7a14df..40deefef87 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_payment.html @@ -16,14 +16,16 @@ + {{ p.fee|floatformat:2 }} {{ event.currency }} {{ p.provider.verbose_name }}
-
+
+ {{ p.form }}
@@ -32,7 +34,7 @@
+ href="{{ view.get_questions_url }}"> {% trans "Go back" %}
@@ -44,4 +46,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} 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/templates/pretixpresale/event/checkout_questions.html b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html index 61136c2235..206f7fe5fb 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_questions.html @@ -33,7 +33,7 @@
+ href="{{ view.get_index_url }}"> {% trans "Go back" %}
@@ -45,4 +45,4 @@
-{% endblock %} \ No newline at end of file +{% endblock %} 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..99de7e299d 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -16,6 +16,10 @@ 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'^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/__init__.py b/src/pretix/presale/views/__init__.py index 5066000531..6beaa45dfe 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -5,9 +5,11 @@ 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 +from pretix.base.signals import register_payment_providers class EventLoginRequiredMixin: @@ -34,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) @@ -59,10 +77,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/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 4cc8f26fa8..e963b645de 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -1,12 +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 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 @@ -75,27 +78,43 @@ 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 }) + 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" + @cached_property def forms(self): """ @@ -114,22 +133,6 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T 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: @@ -162,17 +165,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) @@ -182,46 +185,203 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T return ctx -class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView): +class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, CheckoutView): template_name = "pretixpresale/event/checkout_payment.html" - def get_success_url(self): - return reverse('presale:event.index', 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( + Q(user=self.request.user) & Q(event=self.request.event) + ).aggregate(sum=Sum('price'))['sum'] @cached_property def provider_forms(self): - total = CartPosition.objects.current.filter( - Q(user=self.request.user) & Q(event=self.request.event) - ).aggregate(sum=Sum('price'))['sum'] 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, + 'form': provider.checkout_form_render(self.request), }) return providers + 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, str): + return redirect(str) + elif resp is True: + return redirect(self.get_confirm_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 + ctx['selected'] = self.request.POST.get('payment', self.request.session.get('payment', '')) return ctx + + +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() + 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_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_payment_url()) + if len(self.cartpos) == 0: + messages.warning(request, _('Your cart is empty.')) + 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(self.get_questions_url()) + + 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 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 + + 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())