Enabled asynchronous cart/order actions

This commit is contained in:
Raphael Michel
2015-10-04 19:54:17 +02:00
parent 4c6b292968
commit c4638a3402
28 changed files with 572 additions and 196 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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'

View File

@@ -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:

View File

@@ -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)

View File

@@ -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 = (

View File

@@ -13,6 +13,7 @@
<script type="text/javascript" src="{% static "js/jquery.formset.js" %}"></script>
<script type="text/javascript" src="{% static "bootstrap/dist/js/bootstrap.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixpresale/js/ui/asynctask.js" %}"></script>
{% endcompress %}
{{ html_head|safe }}
<meta name="viewport" content="width=device-width, initial-scale=1">
@@ -63,6 +64,16 @@
{% endblocktrans %}
{% endwith %}
</footer>
<script type="text/javascript">
var default_loading_message = '{% trans "We are processing your request…" %}';
</script>
<div id="loadingmodal">
<i class="fa fa-cog big-rotating-icon"></i>
<h1>{% trans "We are processing your request…" %}</h1>
<p>
{% trans "If this takes longer than a few minutes, please contact us." %}
</p>
</div>
{% compress js %}
<script type="text/javascript" src="{% static "lightbox/js/lightbox.min.js" %}"></script>
{% endcompress %}

View File

@@ -5,7 +5,7 @@
{% block content %}
<h2>{% trans "Confirm order" %}</h2>
<p>{% trans "Please review the details below and confirm your order." %}</p>
<form method="post">
<form method="post" data-asynctask>
{% csrf_token %}
<div class="panel panel-primary cart">
<div class="panel-heading">

View File

@@ -37,7 +37,7 @@
{{ line.count }}
{% if editable %}
<form action="{% url "presale:event.cart.add" event=event.slug organizer=event.organizer.slug %}"
method="post">
method="post" data-asynctask>
{% csrf_token %}
{% if line.variation %}
<input type="hidden" name="variation_{{ line.item.identity }}_{{ line.variation.identity }}"

View File

@@ -50,7 +50,7 @@
</div>
{% endif %}
{% if event.presale_is_running or event.settings.show_items_outside_presale_period %}
<form method="post"
<form method="post" data-asynctask
action="{% url "presale:event.cart.add" organizer=request.event.organizer.slug event=request.event.slug %}?next={{ request.path|urlencode }}">
{% csrf_token %}
{% for tup in items_by_category %}

View File

@@ -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 %}
<div class="thank-you">
<span class="fa fa-check-circle"></span>
<h2>{% trans "Thank you!" %}</h2>
<p>{% trans "Your order has been placed successfully. See below for details." %}</p>
{% if order.status != 'p' %}
<p>{% trans "Your order has been placed successfully. See below for details." %}</p>
{% else %}
<p>{% trans "We successfully received your payment. See below for details." %}</p>
{% endif %}
<p>{% trans "We also sent you an email with a link to this page if you want to come back later." %}</p>
</div>
{% endif %}
@@ -18,7 +23,7 @@
{% include "pretixpresale/event/fragment_order_status.html" with order=order class="pull-right" %}
<div class="clearfix"></div>
</h2>
{% if order.status == "n" %}
{% if order.status == "n" %}
<div class="panel panel-danger">
<div class="panel-heading">
<h3 class="panel-title">
@@ -28,12 +33,14 @@
<div class="panel-body">
{% if can_retry %}
<a href="{% url "presale:event.order.pay" organizer=request.event.organizer.slug event=request.event.slug secret=order.secret order=order.code %}"
class="btn btn-primary pull-right"><i class="fa fa-money"></i> {% trans "Complete payment" %}</a>
class="btn btn-primary pull-right"><i class="fa fa-money"></i> {% trans "Complete payment" %}
</a>
{% endif %}
{{ payment }}
<strong>{% blocktrans trimmed with date=order.expires|date:"SHORT_DATE_FORMAT" %}
Please complete your payment before {{ date }}
{% endblocktrans %}</strong>
<div class="clearfix"></div>
</div>
</div>
@@ -88,7 +95,7 @@
<div class="col-md-12 text-right">
<p>
<a href="{% url 'presale:event.order.cancel' event=request.event.slug organizer=request.event.organizer.slug secret=order.secret order=order.code %}"
class="btn btn-danger">
class="btn btn-danger">
<span class="fa fa-remove"></span>
{% trans "Cancel order" %}
</a>

View File

@@ -0,0 +1,37 @@
{% load compress %}
{% load i18n %}
{% load staticfiles %}
<!DOCTYPE html>
<html>
<head>
<title>{{ settings.PRETIX_INSTANCE_NAME }}</title>
{% compress css %}
<link rel="stylesheet" type="text/less" href="{% static "pretixpresale/less/waiting.less" %}"/>
{% endcompress %}
{% compress js %}
<script type="text/javascript" src="{% static "jquery/js/jquery-2.1.1.min.js" %}"></script>
{% endcompress %}
<meta name="viewport" content="width=device-width, initial-scale=1">
<meta http-equiv="refresh" content="1">
</head>
<body>
<div class="container">
<i class="fa fa-cog big-rotating-icon"></i>
<h1>{% trans "We are processing your request…" %}</h1>
<p>
{% trans "If this takes longer than a few minutes, please contact us." %}
</p>
</div>
<script type="text/javascript">
window.setInterval(function () {
$.get(location.href + '&ajax=1', function (data, status) {
if (data.ready && data.redirect) {
location.href = data.redirect;
}
}, 'json');
}, 2000);
</script>
</body>
</html>

View File

@@ -27,6 +27,9 @@ urlpatterns = [
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/confirm$',
pretix.presale.views.order.OrderPayDo.as_view(),
name='event.order.pay.confirm'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/pay/complete$',
pretix.presale.views.order.OrderPayComplete.as_view(),
name='event.order.pay.complete'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/download/(?P<output>[^/]+)$',
pretix.presale.views.order.OrderDownload.as_view(),
name='event.order.download'),

View File

@@ -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')

View File

@@ -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())

View File

@@ -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,

View File

@@ -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={

View File

@@ -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) {
// }
}

View File

@@ -13,62 +13,15 @@ $(function () {
$(".collapsed").removeClass("collapsed").addClass("collapse");
});
/**
* Module for displaying "Waiting for..." dialog using Bootstrap
*
* @author Eugene Maslovich <ehpc@em42.ru>
* MIT License
*/
var waitingDialog = (function ($) {
// Creating modal dialog's DOM
var $dialog = $(
'<div class="modal fade" data-backdrop="static" data-keyboard="false" tabindex="-1" role="dialog" aria-hidden="true" style="padding-top:15%; overflow-y:visible;">' +
'<div class="modal-dialog modal-m">' +
'<div class="modal-content">' +
'<div class="modal-header"><h3 style="margin:0;"></h3></div>' +
'<div class="modal-body">' +
'<div class="progress progress-striped active" style="margin-bottom:0;"><div class="progress-bar" style="width: 100%"></div></div>' +
'</div>' +
'</div></div></div>');
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");
}
}

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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

View File

@@ -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'

View File

@@ -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)

View File

@@ -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,
)