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 "We are processing your request…" %}

+

+ {% trans "If this takes longer than a few minutes, please contact us." %} +

+
{% compress js %} {% endcompress %} diff --git a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html index 40879fc376..a16a0dad5d 100644 --- a/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html +++ b/src/pretix/presale/templates/pretixpresale/event/checkout_confirm.html @@ -5,7 +5,7 @@ {% block content %}

{% trans "Confirm order" %}

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

-
+ {% csrf_token %}
diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html index 6333c16335..235b797a04 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_cart.html @@ -37,7 +37,7 @@ {{ line.count }} {% if editable %} + method="post" data-asynctask> {% csrf_token %} {% if line.variation %} {% endif %} {% if event.presale_is_running or event.settings.show_items_outside_presale_period %} - {% csrf_token %} {% for tup in items_by_category %} diff --git a/src/pretix/presale/templates/pretixpresale/event/order.html b/src/pretix/presale/templates/pretixpresale/event/order.html index 08bd7e0ebd..450933cc99 100644 --- a/src/pretix/presale/templates/pretixpresale/event/order.html +++ b/src/pretix/presale/templates/pretixpresale/event/order.html @@ -3,11 +3,16 @@ {% load bootstrap3 %} {% block title %}{% trans "Order details" %}{% endblock %} {% block content %} - {% if "thanks" in request.GET %} + {% if "thanks" in request.GET or "paid" in request.GET %}
+

{% trans "Thank you!" %}

-

{% trans "Your order has been placed successfully. See below for details." %}

+ {% if order.status != 'p' %} +

{% trans "Your order has been placed successfully. See below for details." %}

+ {% else %} +

{% trans "We successfully received your payment. See below for details." %}

+ {% endif %}

{% trans "We also sent you an email with a link to this page if you want to come back later." %}

{% endif %} @@ -18,7 +23,7 @@ {% include "pretixpresale/event/fragment_order_status.html" with order=order class="pull-right" %}
- {% if order.status == "n" %} + {% if order.status == "n" %}

@@ -28,12 +33,14 @@
{% if can_retry %} {% trans "Complete payment" %} + class="btn btn-primary pull-right"> {% trans "Complete payment" %} + {% endif %} {{ payment }} {% blocktrans trimmed with date=order.expires|date:"SHORT_DATE_FORMAT" %} Please complete your payment before {{ date }} {% endblocktrans %} +

@@ -88,7 +95,7 @@

+ class="btn btn-danger"> {% trans "Cancel order" %} diff --git a/src/pretix/presale/templates/pretixpresale/waiting.html b/src/pretix/presale/templates/pretixpresale/waiting.html new file mode 100644 index 0000000000..b5b1fe8eef --- /dev/null +++ b/src/pretix/presale/templates/pretixpresale/waiting.html @@ -0,0 +1,37 @@ +{% load compress %} +{% load i18n %} +{% load staticfiles %} + + + + {{ settings.PRETIX_INSTANCE_NAME }} + {% compress css %} + + {% endcompress %} + {% compress js %} + + {% endcompress %} + + + + +

+ + +

{% trans "We are processing your request…" %}

+ +

+ {% trans "If this takes longer than a few minutes, please contact us." %} +

+
+ + + diff --git a/src/pretix/presale/urls.py b/src/pretix/presale/urls.py index 2d7243d3ad..885ac7afa2 100644 --- a/src/pretix/presale/urls.py +++ b/src/pretix/presale/urls.py @@ -27,6 +27,9 @@ urlpatterns = [ url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/pay/confirm$', pretix.presale.views.order.OrderPayDo.as_view(), name='event.order.pay.confirm'), + url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/pay/complete$', + pretix.presale.views.order.OrderPayComplete.as_view(), + name='event.order.pay.complete'), url(r'^order/(?P[^/]+)/(?P[A-Za-z0-9]+)/download/(?P[^/]+)$', pretix.presale.views.order.OrderDownload.as_view(), name='event.order.download'), diff --git a/src/pretix/presale/views/async.py b/src/pretix/presale/views/async.py new file mode 100644 index 0000000000..93b0e46c54 --- /dev/null +++ b/src/pretix/presale/views/async.py @@ -0,0 +1,122 @@ +import logging + +from django.conf import settings +from django.contrib import messages +from django.http import JsonResponse +from django.shortcuts import redirect, render +from django.utils.translation import ugettext as _ + +logger = logging.getLogger('pretix.presale.async') + + +class AsyncAction: + task = None + success_url = None + error_url = None + + def do(self, *args): + if settings.HAS_CELERY: + from pretix.celery import app + + if hasattr(self.task, 'task') and isinstance(self.task.task, app.Task): + return self._do_celery(args) + else: + raise TypeError('Method has no task attached') + else: + return self._do_sync(args) + + def get_success_url(self, value): + return self.success_url + + def get_error_url(self): + return self.error_url + + def get_check_url(self, task_id, ajax): + return self.request.path + '?async_id=%s' % task_id + ('&ajax=1' if ajax else '') + + def get(self, request, *args, **kwargs): + if 'async_id' in request.GET and settings.HAS_CELERY: + return self.get_result(request) + return self.http_method_not_allowed(request) + + def get_result(self, request): + from celery.result import AsyncResult + res = AsyncResult(request.GET.get('async_id')) + if 'ajax' in self.request.GET: + data = { + 'async_id': res.id, + 'ready': res.ready() + } + if res.ready(): + if res.successful(): + smes = self.get_success_message(res.info) + if smes: + messages.success(self.request, smes) + # TODO: Do not store message if the ajax client stats that it will not redirect + # but handle the mssage itself + data.update({ + 'redirect': self.get_success_url(res.info), + 'message': self.get_success_message(res.info) + }) + else: + messages.error(self.request, self.get_error_message(res.info)) + # TODO: Do not store message if the ajax client stats that it will not redirect + # but handle the mssage itself + data.update({ + 'redirect': self.get_error_url(), + 'message': self.get_error_message(res.info) + }) + return JsonResponse(data) + else: + if res.ready(): + if res.successful(): + return self.success(res.info) + else: + return self.error(res.info) + return render(request, 'pretixpresale/waiting.html') + + def _do_celery(self, args): + rs = self.task.task.apply_async(args=args) + if 'ajax' in self.request.GET or 'ajax' in self.request.POST: + return JsonResponse({ + 'async_id': rs.id, + 'check_url': self.get_check_url(rs.id, True) + }) + else: + return redirect(self.get_check_url(rs.id, False)) + + def _do_sync(self, args): + try: + rs = getattr(self.__class__, 'task')(*args) + return self.success(rs) + except Exception as e: + logger.exception('Error while executing task synchronously') + return self.error(e) + + def success(self, value): + smes = self.get_success_message(value) + if smes: + messages.success(self.request, smes) + if "ajax" in self.request.POST or "ajax" in self.request.GET: + return JsonResponse({ + 'ready': True, + 'redirect': self.get_success_url(value), + 'message': self.get_success_message(value) + }) + return redirect(self.get_success_url(value)) + + def error(self, exception): + messages.error(self.request, self.get_error_message(exception)) + if "ajax" in self.request.POST or "ajax" in self.request.GET: + return JsonResponse({ + 'ready': True, + 'redirect': self.get_error_url(), + 'message': self.get_error_message(exception) + }) + return redirect(self.get_error_url()) + + def get_error_message(self, exception): + return _('An unexpected error has occured') + + def get_success_message(self, value): + return _('The task has been completed') diff --git a/src/pretix/presale/views/cart.py b/src/pretix/presale/views/cart.py index 4e7aa189e4..c835056545 100644 --- a/src/pretix/presale/views/cart.py +++ b/src/pretix/presale/views/cart.py @@ -1,13 +1,15 @@ from django.contrib import messages from django.core.urlresolvers import reverse +from django.http import JsonResponse from django.shortcuts import redirect -from django.utils.translation import ugettext_lazy as _ +from django.utils.translation import ugettext as _ from django.views.generic import View from pretix.base.services.cart import ( CartError, add_items_to_cart, remove_items_from_cart, ) from pretix.presale.views import EventViewMixin +from pretix.presale.views.async import AsyncAction class CartActionMixin: @@ -15,18 +17,16 @@ class CartActionMixin: def get_next_url(self): if "next" in self.request.GET and '://' not in self.request.GET: return self.request.GET.get('next') - elif "HTTP_REFERER" in self.request.META: - return self.request.META.get('HTTP_REFERER') else: return reverse('presale:event.index', kwargs={ 'event': self.request.event.slug, 'organizer': self.request.event.organizer.slug, }) - def get_success_url(self): + def get_success_url(self, value=None): return self.get_next_url() - def get_failure_url(self): + def get_error_url(self): return self.get_next_url() def _items_from_post_data(self): @@ -61,27 +61,34 @@ class CartRemove(EventViewMixin, CartActionMixin, View): def post(self, *args, **kwargs): items = self._items_from_post_data() if not items: - return redirect(self.get_failure_url()) + return redirect(self.get_error_url()) remove_items_from_cart(self.request.event.identity, items, self.request.session.session_key) messages.success(self.request, _('Your cart has been updated.')) return redirect(self.get_success_url()) -class CartAdd(EventViewMixin, CartActionMixin, View): +class CartAdd(EventViewMixin, CartActionMixin, AsyncAction, View): + task = add_items_to_cart - def __init__(self, *args, **kwargs): - super().__init__(*args, **kwargs) + def get_success_message(self, value): + return _('The products have been successfully added to your cart.') + + def get_error_message(self, exception): + if isinstance(exception, dict) and exception['exc_type'] == 'CartError': + return exception['exc_message'] + elif isinstance(exception, CartError): + return str(exception) + return super().get_error_message(exception) def post(self, request, *args, **kwargs): items = self._items_from_post_data() - return self.process(items) - - def process(self, items): - try: - add_items_to_cart(self.request.event.identity, items, self.request.session.session_key) - messages.success(self.request, _('The products have been successfully added to your cart.')) - return redirect(self.get_success_url()) - except CartError as e: - messages.error(self.request, str(e)) - return redirect(self.get_failure_url()) + if items: + return self.do(self.request.event.identity, items, self.request.session.session_key) + else: + if 'ajax' in self.request.GET or 'ajax' in self.request.POST: + return JsonResponse({ + 'redirect': self.get_error_url() + }) + else: + return redirect(self.get_error_url()) diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index d382b4871d..a6fd970a99 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -12,7 +12,7 @@ from pretix.presale.views import CartMixin class CheckoutView(CartMixin, View): def dispatch(self, request, *args, **kwargs): self.request = request - if not self.positions: + if not self.positions and "async_id" not in request.GET: messages.error(request, _("Your cart is empty")) return redirect(reverse('presale:event.index', kwargs={ 'organizer': self.request.event.organizer.slug, diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index d436c94e9a..c0147ee397 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -107,7 +107,7 @@ class OrderPay(EventViewMixin, OrderDetailMixin, TemplateView): or not self.payment_provider.order_can_retry(self.order) or not self.payment_provider.is_enabled): messages.error(request, _('The payment for this order cannot be continued.')) - return redirect(self.get_order_url()) + return redirect(self.get_order_url() + '?paid=yes') return super().dispatch(request, *args, **kwargs) def post(self, request, *args, **kwargs): @@ -168,9 +168,30 @@ class OrderPayDo(EventViewMixin, OrderDetailMixin, TemplateView): ctx['payment_provider'] = self.payment_provider return ctx - @cached_property - def form(self): - return self.payment_provider.payment_form_render(self.request) + def get_payment_url(self): + return reverse('presale:event.order.pay', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug, + 'order': self.order.code, + 'secret': self.order.secret + }) + + +class OrderPayComplete(EventViewMixin, OrderDetailMixin, View): + def dispatch(self, request, *args, **kwargs): + self.request = request + if not self.order: + raise Http404(_('Unknown order code or not authorized to access this order.')) + if (not self.payment_provider.payment_is_valid_session(request) + or not self.payment_provider.is_enabled + or not self.payment_provider.is_allowed(request)): + messages.error(request, _('The payment information you entered was incomplete.')) + return redirect(self.get_payment_url()) + return super().dispatch(request, *args, **kwargs) + + def get(self, request, *args, **kwargs): + resp = self.payment_provider.payment_perform(request, self.order) + return redirect(resp or self.get_order_url() + '?paid=yes') def get_payment_url(self): return reverse('presale:event.order.pay', kwargs={ diff --git a/src/static/pretixpresale/js/ui/asynctask.js b/src/static/pretixpresale/js/ui/asynctask.js new file mode 100644 index 0000000000..d6e2a79144 --- /dev/null +++ b/src/static/pretixpresale/js/ui/asynctask.js @@ -0,0 +1,65 @@ +var async_task_id = null; +var async_task_timeout = null; +var async_task_check_url = null; + +$(function () { + $("body").on('submit', 'form[data-asynctask]', function (e) { + e.preventDefault(); + if ($(this).data('ajaxing')) return; + $(this).data('ajaxing', true); + waitingDialog.show(default_loading_message); + + $.ajax( + { + 'type': 'POST', + 'url': $(this).attr('action'), + 'data': $(this).serialize() + '&ajax=1', + 'success': async_task_callback, + 'error': async_task_error, + 'context': this, + 'dataType': 'json' + } + ); + }); +}); + +function async_task_check() { + $.ajax( + { + 'type': 'GET', + 'url': async_task_check_url, + 'success': async_task_check_callback, + 'error': async_task_error, + 'context': this, + 'dataType': 'json' + } + ); +} + +function async_task_check_callback(data, jqXHR, status) { + if (data.ready && data.redirect) { + location.href = data.redirect; + return; + } + async_task_timeout = window.setTimeout(async_task_check, 500); +} + +function async_task_callback(data, jqXHR, status) { + $(this).data('ajaxing', false); + if (data.redirect) { + location.href = data.redirect; + return; + } + async_task_id = data.async_id; + async_task_check_url = data.check_url; + async_task_timeout = window.setTimeout(async_task_check, 500); +} + +function async_task_error(jqXHR, textStatus, errorThrown) { + waitingDialog.hide(); + // TODO + // if(jqXHR.status == 500) { + // } if(jqXHR.status == 403) { + // } if(jqXHR.status == 503) { + // } +} diff --git a/src/static/pretixpresale/js/ui/main.js b/src/static/pretixpresale/js/ui/main.js index f237684ffc..1fd027ad87 100644 --- a/src/static/pretixpresale/js/ui/main.js +++ b/src/static/pretixpresale/js/ui/main.js @@ -13,62 +13,15 @@ $(function () { $(".collapsed").removeClass("collapsed").addClass("collapse"); }); - -/** - * Module for displaying "Waiting for..." dialog using Bootstrap - * - * @author Eugene Maslovich - * MIT License - */ - var waitingDialog = (function ($) { - // Creating modal dialog's DOM - var $dialog = $( - ''); - return { - /** - * Opens our dialog - * @param message Custom message - * @param options Custom options: - * options.dialogSize - bootstrap postfix for dialog size, e.g. "sm", "m"; - * options.progressType - bootstrap postfix for progress bar type, e.g. "success", "warning". - */ show: function (message, options) { - // Assigning defaults - var settings = $.extend({ - dialogSize: 'm', - progressType: '' - }, options); - if (typeof message === 'undefined') { - message = 'Loading'; - } - if (typeof options === 'undefined') { - options = {}; - } - // Configuring dialog - $dialog.find('.modal-dialog').attr('class', 'modal-dialog').addClass('modal-' + settings.dialogSize); - $dialog.find('.progress-bar').attr('class', 'progress-bar'); - if (settings.progressType) { - $dialog.find('.progress-bar').addClass('progress-bar-' + settings.progressType); - } - $dialog.find('h3').text(message); - // Opening dialog - $dialog.modal(); + $("#loadingmodal h1").html(message); + $("body").addClass("loading"); }, - /** - * Closes dialog - */ hide: function () { - $dialog.modal('hide'); + $("body").removeClass("loading"); } } diff --git a/src/static/pretixpresale/less/main.less b/src/static/pretixpresale/less/main.less index 43b9c2808f..2ef78f5650 100644 --- a/src/static/pretixpresale/less/main.less +++ b/src/static/pretixpresale/less/main.less @@ -56,6 +56,44 @@ a:hover .panel-primary > .panel-heading { color: @brand-success; } } + +body.loading .container { + -webkit-filter: blur(2px); + -moz-filter: blur(2px); + -ms-filter: blur(2px); + -o-filter: blur(2px); + filter: blur(2px); +} + +#loadingmodal { + position: fixed; + top: 0; + left: 0; + width: 100vw; + height: 100vh; + background: rgba(255, 255, 255, .7); + opacity: 0; + text-align: center; + z-index: 900000; + visibility: hidden; + + .big-rotating-icon { + margin-top: 50px; + -webkit-animation: fa-spin 8s infinite linear; + animation: fa-spin 8s infinite linear; + font-size: 200px; + color: @brand-primary; + } +} + +.loading #loadingmodal { + opacity: 1; + visibility: visible; + transition: opacity .5s ease-in-out; + -moz-transition: opacity .5s ease-in-out; + -webkit-transition: opacity .5s ease-in-out; +} + @media (min-width: @screen-md-min) { .thank-you { height: 170px; diff --git a/src/static/pretixpresale/less/waiting.less b/src/static/pretixpresale/less/waiting.less new file mode 100644 index 0000000000..0fee8b05dc --- /dev/null +++ b/src/static/pretixpresale/less/waiting.less @@ -0,0 +1,19 @@ +@import "../../bootstrap/less/bootstrap.less"; +@import "../../fontawesome/less/font-awesome.less"; +@import "../../pretixbase/less/colors.less"; + +@fa-font-path: "../../fontawesome/fonts"; + +body { + background: #ececec; + text-align: center; + padding: 50px 0; +} + +.big-rotating-icon { + margin-top: 50px; + -webkit-animation: fa-spin 8s infinite linear; + animation: fa-spin 8s infinite linear; + font-size: 200px; + color: @brand-primary; +} diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 78e0cd4704..35465d6cf0 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -5,7 +5,7 @@ from django.utils.timezone import now from pretix.base.models import Event, Organizer from pretix.base.payment import FreeOrderProvider -from pretix.base.services.orders import place_order +from pretix.base.services.orders import _create_order @pytest.fixture @@ -22,9 +22,9 @@ def event(): def test_expiry_days(event): today = now() event.settings.set('payment_term_days', 5) - order = place_order(event, email='dummy@example.org', positions=[], - dt=today, payment_provider=FreeOrderProvider(event), - locale='de') + order = _create_order(event, email='dummy@example.org', positions=[], + dt=today, payment_provider=FreeOrderProvider(event), + locale='de') assert (order.expires - today).days == 5 @@ -33,12 +33,12 @@ def test_expiry_last(event): today = now() event.settings.set('payment_term_days', 5) event.settings.set('payment_term_last', now() + timedelta(days=3)) - order = place_order(event, email='dummy@example.org', positions=[], - dt=today, payment_provider=FreeOrderProvider(event), - locale='de') + order = _create_order(event, email='dummy@example.org', positions=[], + dt=today, payment_provider=FreeOrderProvider(event), + locale='de') assert (order.expires - today).days == 3 event.settings.set('payment_term_last', now() + timedelta(days=7)) - order = place_order(event, email='dummy@example.org', positions=[], - dt=today, payment_provider=FreeOrderProvider(event), - locale='de') + order = _create_order(event, email='dummy@example.org', positions=[], + dt=today, payment_provider=FreeOrderProvider(event), + locale='de') assert (order.expires - today).days == 5 diff --git a/src/tests/presale/test_cart.py b/src/tests/presale/test_cart.py index ec37dd287f..d84ef1120e 100644 --- a/src/tests/presale/test_cart.py +++ b/src/tests/presale/test_cart.py @@ -217,23 +217,20 @@ class CartTest(CartTestMixin, TestCase): self.assertGreater(cp.expires, now()) def test_renew_expired_successfully(self): - CartPosition.objects.create( + cp1 = CartPosition.objects.create( event=self.event, session=self.session_key, item=self.ticket, price=23, expires=now() - timedelta(minutes=10) ) self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'variation_%s_%s' % (self.shirt.identity, self.shirt_red.identity): '1' }, follow=True) - objs = list(CartPosition.objects.current.filter(session=self.session_key, event=self.event)) - self.assertEqual(len(objs), 1) - self.assertEqual(objs[0].item, self.ticket) - self.assertIsNone(objs[0].variation) - self.assertEqual(objs[0].price, 23) - self.assertGreater(objs[0].expires, now()) + obj = CartPosition.objects.current.get(identity=cp1.identity) + self.assertEqual(obj.item, self.ticket) + self.assertIsNone(obj.variation) + self.assertEqual(obj.price, 23) + self.assertGreater(obj.expires, now()) def test_renew_questions(self): - """ - Currently fails. See: https://github.com/pretix/pretix/issues/20 - """ cr1 = CartPosition.objects.create( event=self.event, session=self.session_key, item=self.ticket, price=23, expires=now() - timedelta(minutes=10) @@ -247,23 +244,24 @@ class CartTest(CartTestMixin, TestCase): cartposition=cr1, question=q1, answer='23' )) self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '1', }, follow=True) - objs = list(CartPosition.objects.current.filter(session=self.session_key, event=self.event)) - self.assertEqual(len(objs), 1) - self.assertEqual(objs[0].answers.get(question=q1).answer, '23') + obj = CartPosition.objects.current.get(identity=cr1.identity) + self.assertEqual(obj.answers.get(question=q1).answer, '23') def test_renew_expired_failed(self): self.quota_tickets.size = 0 self.quota_tickets.save() - CartPosition.objects.create( + cp1 = CartPosition.objects.create( event=self.event, session=self.session_key, item=self.ticket, price=23, expires=now() - timedelta(minutes=10) ) response = self.client.post('/%s/%s/cart/add' % (self.orga.slug, self.event.slug), { + 'item_' + self.ticket.identity: '1', }, follow=True) doc = BeautifulSoup(response.rendered_content) self.assertIn('no longer available', doc.select('.alert-danger')[0].text) - self.assertFalse(CartPosition.objects.current.filter(session=self.session_key, event=self.event).exists()) + self.assertFalse(CartPosition.objects.current.filter(identity=cp1.identity).exists()) def test_restriction_ok(self): self.event.plugins = 'tests.testdummy' diff --git a/src/tests/presale/test_event.py b/src/tests/presale/test_event.py index 34858d0962..3c38637b1f 100644 --- a/src/tests/presale/test_event.py +++ b/src/tests/presale/test_event.py @@ -12,7 +12,6 @@ from pretix.base.models import ( class EventTestMixin: - def setUp(self): super().setUp() self.orga = Organizer.objects.create(name='CCC', slug='ccc') @@ -23,7 +22,6 @@ class EventTestMixin: class EventMiddlewareTest(EventTestMixin, BrowserTest): - def setUp(self): super().setUp() self.driver.implicitly_wait(10) @@ -38,7 +36,6 @@ class EventMiddlewareTest(EventTestMixin, BrowserTest): class ItemDisplayTest(EventTestMixin, BrowserTest): - def setUp(self): super().setUp() self.driver.implicitly_wait(10) @@ -141,6 +138,11 @@ class ItemDisplayTest(EventTestMixin, BrowserTest): class DeadlineTest(EventTestMixin, TestCase): + def setUp(self): + super().setUp() + q = Quota.objects.create(event=self.event, name='Quota', size=2) + self.item = Item.objects.create(event=self.event, name='Early-bird ticket', default_price=0, active=False) + q.items.add(self.item) def test_not_yet_started(self): self.event.presale_start = now() + datetime.timedelta(days=1) @@ -153,6 +155,9 @@ class DeadlineTest(EventTestMixin, TestCase): self.assertNotIn('checkout-button-row', response.rendered_content) response = self.client.post( '/%s/%s/cart/add' % (self.orga.slug, self.event.slug), + { + 'item_' + self.item.identity: '1', + }, follow=True ) self.assertIn('alert-danger', response.rendered_content) @@ -169,6 +174,9 @@ class DeadlineTest(EventTestMixin, TestCase): self.assertNotIn('checkout-button-row', response.rendered_content) response = self.client.post( '/%s/%s/cart/add' % (self.orga.slug, self.event.slug), + { + 'item_' + self.item.identity: '1' + }, follow=True ) self.assertIn('alert-danger', response.rendered_content) @@ -185,7 +193,10 @@ class DeadlineTest(EventTestMixin, TestCase): self.assertNotIn('alert-info', response.rendered_content) self.assertIn('checkout-button-row', response.rendered_content) response = self.client.post( - '/%s/%s/cart/add' % (self.orga.slug, self.event.slug) + '/%s/%s/cart/add' % (self.orga.slug, self.event.slug), + { + 'item_' + self.item.identity: '1' + } ) self.assertNotEqual(response.status_code, 403) @@ -200,6 +211,9 @@ class DeadlineTest(EventTestMixin, TestCase): self.assertNotIn('alert-info', response.rendered_content) self.assertIn('checkout-button-row', response.rendered_content) response = self.client.post( - '/%s/%s/cart/add' % (self.orga.slug, self.event.slug) + '/%s/%s/cart/add' % (self.orga.slug, self.event.slug), + { + 'item_' + self.item.identity: '1' + } ) self.assertNotEqual(response.status_code, 403) diff --git a/src/tests/presale/test_orders.py b/src/tests/presale/test_orders.py index b55bc91d96..43031574b3 100644 --- a/src/tests/presale/test_orders.py +++ b/src/tests/presale/test_orders.py @@ -7,7 +7,7 @@ from django.utils.timezone import now from pretix.base.models import ( Event, Item, ItemCategory, ItemVariation, Order, OrderPosition, Organizer, - Property, PropertyValue, Question, Quota, User, + Property, PropertyValue, Question, Quota, )