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 000000000..993c63e27 --- /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 000000000..296797427 --- /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 a3c509958..0524ad872 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 090e8d1c3..48f9e0e9e 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 156656b87..97019616f 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 4eeb6236a..99de7e299 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 0052d7522..1f531749e 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 0fa792d3f..e963b645d 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())