diff --git a/src/pretix/base/__init__.py b/src/pretix/base/__init__.py index 48435281d9..13d6de6b37 100644 --- a/src/pretix/base/__init__.py +++ b/src/pretix/base/__init__.py @@ -8,7 +8,7 @@ class PretixBaseConfig(AppConfig): def ready(self): from . import exporter # NOQA from . import payment # NOQA - from .services import export, mail, tickets # NOQA + from .services import export, mail, tickets, cart, orders # NOQA try: from .celery import app as celery_app # NOQA diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 1d4025860e..582b026ce7 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -251,7 +251,7 @@ class BasePaymentProvider: """ After the user confirmed his purchase, this method will be called to complete the payment process. This is the place to actually move the money, if applicable. - If you need any speical behaviour, you can return a string + If you need any special behaviour, you can return a string containing an URL the user will be redirected to. If you are done with your process you should return the user to the order's detail page. diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index bd854e55ec..5727548a49 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1,5 +1,6 @@ from datetime import timedelta +from django.conf import settings from django.db.models import Q from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ @@ -16,7 +17,7 @@ class CartError(Exception): error_messages = { 'busy': _('We were not able to process your request completely as the ' 'server was too busy. Please try again.'), - 'empty': _('You did not select any items.'), + 'empty': _('You did not select any products.'), 'not_for_sale': _('You selected a product which is not available for sale.'), 'unavailable': _('Some of the products you selected were no longer available. ' 'Please see below for details.'), @@ -128,6 +129,27 @@ def _add_items(event, items, session, expiry): return err +def _add_items_to_cart(event: Event, items: list, session: str=None): + with event.lock(): + _check_date(event) + existing = CartPosition.objects.current.filter(Q(session=session) & Q(event=event)).count() + if sum(i[2] for i in items) + existing > int(event.settings.max_items_per_order): + # TODO: i18n plurals + raise CartError(error_messages['max_items'] % event.settings.max_items_per_order) + + expiry = now() + timedelta(minutes=event.settings.get('reservation_time', as_type=int)) + _extend_existing(event, session, expiry) + + expired = _re_add_expired_positions(items, event, session) + if not items: + raise CartError(error_messages['empty']) + + err = _add_items(event, items, session, expiry) + _delete_expired(expired) + if err: + raise CartError(err) + + def add_items_to_cart(event: str, items: list, session: str=None): """ Adds a list of items to a user's cart. @@ -138,24 +160,7 @@ def add_items_to_cart(event: str, items: list, session: str=None): """ event = Event.objects.current.get(identity=event) try: - with event.lock(): - _check_date(event) - existing = CartPosition.objects.current.filter(Q(session=session) & Q(event=event)).count() - if sum(i[2] for i in items) + existing > int(event.settings.max_items_per_order): - # TODO: i18n plurals - raise CartError(error_messages['max_items'] % event.settings.max_items_per_order) - - expiry = now() + timedelta(minutes=event.settings.get('reservation_time', as_type=int)) - _extend_existing(event, session, expiry) - - expired = _re_add_expired_positions(items, event, session) - if not items: - raise CartError(error_messages['empty']) - - err = _add_items(event, items, session, expiry) - _delete_expired(expired) - if err: - raise CartError(err) + return _add_items_to_cart(event, items, session) except EventLock.LockTimeoutException: raise CartError(error_messages['busy']) @@ -177,3 +182,17 @@ def remove_items_from_cart(event: str, items: list, session: str=None): cw &= Q(variation__isnull=True) for cp in CartPosition.objects.current.filter(cw).order_by("-price")[:cnt]: cp.delete() + + +if settings.HAS_CELERY: + from pretix.celery import app + + @app.task(bind=True, max_retries=5, default_retry_delay=2) + def add_items_to_cart_task(self, event: str, items: list, session: str): + event = Event.objects.current.get(identity=event) + try: + return _add_items_to_cart(event, items, session) + except EventLock.LockTimeoutException: + self.retry(exc=CartError(error_messages['busy'])) + + add_items_to_cart.task = add_items_to_cart_task diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 2a629c2cf0..8502ce0ac1 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -1,13 +1,19 @@ from datetime import datetime, timedelta +from django.conf import settings from django.db import transaction from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ -from pretix.base.models import Event, EventLock, Order, OrderPosition, Quota +from pretix.base.models import ( + CartPosition, Event, EventLock, Order, OrderPosition, Quota, +) from pretix.base.payment import BasePaymentProvider +from pretix.base.services.cart import CartError from pretix.base.services.mail import mail -from pretix.base.signals import order_paid, order_placed +from pretix.base.signals import ( + order_paid, order_placed, register_payment_providers, +) from pretix.helpers.urls import build_absolute_uri error_messages = { @@ -18,6 +24,7 @@ error_messages = { 'price_changed': _('The price of some of the items in your cart has changed in the ' 'meantime. Please see below for details.'), 'max_items': _("You cannot select more than %s items per order"), + 'internal': _("An internal error occured, please try again."), 'busy': _('We were not able to process your request completely as the ' 'server was too busy. Please try again.'), } @@ -85,7 +92,7 @@ def _check_date(event): raise OrderError(error_messages['ended']) -def check_positions(event: Event, dt: datetime, positions: list): +def _check_positions(event: Event, dt: datetime, positions: list): err = None _check_date(event) @@ -130,41 +137,9 @@ def check_positions(event: Event, dt: datetime, positions: list): raise OrderError(err) -def perform_order(event: Event, payment_provider: BasePaymentProvider, positions: list, - email: str=None, locale: str=None): - dt = now() - - try: - with event.lock(): - check_positions(event, dt, positions) - order = place_order(event, email, positions, dt, payment_provider, - locale=locale) - mail( - order.email, _('Your order: %(code)s') % {'code': order.code}, - 'pretixpresale/email/order_placed.txt', - { - 'order': order, - 'event': event, - 'url': build_absolute_uri('presale:event.order', kwargs={ - 'event': event.slug, - 'organizer': event.organizer.slug, - 'order': order.code, - 'secret': order.secret - }), - 'payment': payment_provider.order_pending_mail_render(order) - }, - event, locale=order.locale - ) - return order - except EventLock.LockTimeoutException: - # Is raised when there are too many threads asking for event locks and we were - # unable to get one - raise OrderError(error_messages['busy']) - - @transaction.atomic() -def place_order(event: Event, email: str, positions: list, dt: datetime, - payment_provider: BasePaymentProvider, locale: str=None): +def _create_order(event: Event, email: str, positions: list, dt: datetime, + payment_provider: BasePaymentProvider, locale: str=None): total = sum([c.price for c in positions]) payment_fee = payment_provider.calculate_fee(total) total += payment_fee @@ -180,8 +155,72 @@ def place_order(event: Event, email: str, positions: list, dt: datetime, locale=locale, total=total, payment_fee=payment_fee, - payment_provider=payment_provider.identifier, + payment_provider=payment_provider.identifier ) OrderPosition.transform_cart_positions(positions, order) order_placed.send(event, order=order) return order + + +def _perform_order(event: Event, payment_provider: BasePaymentProvider, position_ids: list, + email: str, locale: str): + event = Event.objects.current.get(identity=event) + responses = register_payment_providers.send(event) + pprov = None + for receiver, response in responses: + provider = response(event) + if provider.identifier == payment_provider: + pprov = provider + if not pprov: + raise OrderError(error_messages['internal']) + + dt = now() + with event.lock(): + positions = list(CartPosition.objects.current.filter( + identity__in=position_ids).select_related('item', 'variation')) + if len(position_ids) != len(positions): + raise OrderError(error_messages['internal']) + _check_positions(event, dt, positions) + order = _create_order(event, email, positions, dt, pprov, + locale=locale) + mail( + order.email, _('Your order: %(code)s') % {'code': order.code}, + 'pretixpresale/email/order_placed.txt', + { + 'order': order, + 'event': event, + 'url': build_absolute_uri('presale:event.order', kwargs={ + 'event': event.slug, + 'organizer': event.organizer.slug, + 'order': order.code, + 'secret': order.secret + }), + 'payment': pprov.order_pending_mail_render(order) + }, + event, locale=order.locale + ) + return order.identity + + +def perform_order(event: str, payment_provider: str, positions: list, + email: str=None, locale: str=None): + try: + return _perform_order(event, payment_provider, positions, email, locale) + except EventLock.LockTimeoutException: + # Is raised when there are too many threads asking for event locks and we were + # unable to get one + raise OrderError(error_messages['busy']) + + +if settings.HAS_CELERY: + from pretix.celery import app + + @app.task(bind=True, max_retries=5, default_retry_delay=2) + def perform_order_task(self, event: str, payment_provider: str, positions: list, + email: str=None, locale: str=None): + try: + return _perform_order(event, payment_provider, positions, email, locale) + except EventLock.LockTimeoutException: + self.retry(exc=OrderError(error_messages['busy'])) + + perform_order.task = perform_order_task diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 5f77f5ca93..711ef37b01 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -181,7 +181,6 @@ class Paypal(BasePaymentProvider): try: mark_order_paid(order, 'paypal', json.dumps(payment.to_dict())) - messages.success(request, _('We successfully received your payment. Thank you!')) except Quota.QuotaExceededException as e: messages.error(request, str(e)) return None diff --git a/src/pretix/plugins/paypal/views.py b/src/pretix/plugins/paypal/views.py index 4165daeeb0..b79731ad40 100644 --- a/src/pretix/plugins/paypal/views.py +++ b/src/pretix/plugins/paypal/views.py @@ -23,9 +23,10 @@ def success(request): request.session['payment_paypal_payer'] = payer try: event = Event.objects.current.get(identity=request.session['payment_paypal_event']) - return redirect('presale:event.checkout.confirm', + return redirect('presale:event.checkout', event=event.slug, - organizer=event.organizer.slug) + organizer=event.organizer.slug, + step='confirm') except Event.DoesNotExist: pass # TODO: Handle this else: @@ -37,9 +38,10 @@ def abort(request): messages.error(request, _('It looks like you cancelled the PayPal payment')) try: event = Event.objects.current.get(identity=request.session['payment_paypal_event']) - return redirect('presale:event.checkout.payment', + return redirect('presale:event.checkout', event=event.slug, - organizer=event.organizer.slug) + organizer=event.organizer.slug, + step='payment') except Event.DoesNotExist: pass # TODO: Handle this @@ -104,4 +106,5 @@ def retry(request, order): return redirect('presale:event.order', event=order.event.slug, organizer=order.event.organizer.slug, - order=order.code) + order=order.code, + secret=order.secret) + '?paid=yes' diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index c041f3d6df..74b8f0db48 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -116,7 +116,6 @@ class Stripe(BasePaymentProvider): if charge.status == 'succeeded' and charge.paid: try: mark_order_paid(order, 'stripe', str(charge)) - messages.success(request, _('We successfully received your payment. Thank you!')) except Quota.QuotaExceededException as e: messages.error(request, str(e)) else: diff --git a/src/pretix/plugins/stripe/signals.py b/src/pretix/plugins/stripe/signals.py index e6c85b941a..70a6361a91 100644 --- a/src/pretix/plugins/stripe/signals.py +++ b/src/pretix/plugins/stripe/signals.py @@ -20,7 +20,7 @@ def html_head_presale(sender, request=None, **kwargs): provider = Stripe(sender) url = resolve(request.path_info) - if provider.is_enabled and ("checkout.payment" in url.url_name or "order.pay" in url.url_name): + if provider.is_enabled and ("checkout" in url.url_name or "order.pay" in url.url_name): template = get_template('pretixplugins/stripe/presale_head.html') ctx = Context({'event': sender, 'settings': provider.settings}) return template.render(ctx) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 653e57cbc8..79ea409e37 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1,3 +1,4 @@ +from django.conf import settings from django.contrib import messages from django.core.exceptions import ValidationError from django.core.urlresolvers import reverse @@ -10,12 +11,13 @@ from django.utils.functional import cached_property from django.utils.translation import ugettext_lazy as _ from django.views.generic.base import TemplateResponseMixin -from pretix.base.models import CartPosition +from pretix.base.models import CartPosition, Order from pretix.base.services.orders import OrderError, perform_order from pretix.base.signals import register_payment_providers from pretix.presale.forms.checkout import ContactForm from pretix.presale.signals import checkout_flow_steps from pretix.presale.views import CartMixin +from pretix.presale.views.async import AsyncAction from pretix.presale.views.questions import QuestionsViewMixin @@ -278,10 +280,11 @@ class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep): return True -class ConfirmStep(CartMixin, TemplateFlowStep): +class ConfirmStep(CartMixin, AsyncAction, TemplateFlowStep): priority = 1001 identifier = "confirm" template_name = "pretixpresale/event/checkout_confirm.html" + task = perform_order def is_applicable(self, request): return True @@ -304,28 +307,47 @@ class ConfirmStep(CartMixin, TemplateFlowStep): if provider.identifier == self.request.session['payment']: return provider + def get(self, request): + self.request = request + if 'async_id' in request.GET and settings.HAS_CELERY: + return self.get_result(request) + return TemplateFlowStep.get(self, request) + def post(self, request): self.request = request - try: - order = perform_order(self.request.event, self.payment_provider, self.positions, - email=request.session.get('email', None), - locale=translation.get_language()) - except OrderError as e: - messages.error(request, str(e)) - return redirect(self.get_step_url()) - else: - # Message is delivered via GET parameter - # messages.success(request, _('Your order has been placed.')) - resp = self.payment_provider.payment_perform(request, order) - return redirect(resp or self.get_order_url(order)) + return self.do(self.request.event.identity, self.payment_provider.identifier, + [p.identity for p in self.positions], request.session.get('email'), + translation.get_language()) + + def get_success_message(self, value): + return None + + def success(self, value): + # Message is delivered via GET parameter + # messages.success(request, _('Your order has been placed.')) + return redirect(self.get_success_url(value)) + + def get_success_url(self, value): + order = Order.objects.current.get(identity=value) + return self.get_order_url(order) + + def get_error_message(self, exception): + if isinstance(exception, dict) and exception['exc_type'] == 'OrderError': + return exception['exc_message'] + elif isinstance(exception, OrderError): + return str(exception) + return super().get_error_message(exception) + + def get_error_url(self): + return self.get_step_url() def get_order_url(self, order): - return reverse('presale:event.order', kwargs={ + return reverse('presale:event.order.pay.complete', kwargs={ 'event': self.request.event.slug, 'organizer': self.request.event.organizer.slug, 'order': order.code, 'secret': order.secret - }) + '?thanks=yes' + }) DEFAULT_FLOW = ( diff --git a/src/pretix/presale/templates/pretixpresale/event/base.html b/src/pretix/presale/templates/pretixpresale/event/base.html index a14e060d67..9cb34db1ab 100644 --- a/src/pretix/presale/templates/pretixpresale/event/base.html +++ b/src/pretix/presale/templates/pretixpresale/event/base.html @@ -13,6 +13,7 @@ + {% endcompress %} {{ html_head|safe }} @@ -63,6 +64,16 @@ {% endblocktrans %} {% endwith %} + +
+ {% trans "If this takes longer than a few minutes, please contact us." %} +
+{% trans "Please review the details below and confirm your order." %}
-