Refactored checkout steps

This commit is contained in:
Raphael Michel
2015-10-04 17:14:53 +02:00
parent 2e9157cbef
commit 4c6b292968
15 changed files with 549 additions and 348 deletions

View File

@@ -3,7 +3,7 @@ from decimal import Decimal
from django import forms
from django.contrib import messages
from django.db.models import Sum
from django.db.models import Q, Sum
from django.dispatch import receiver
from django.forms import Form
from django.http import HttpRequest
@@ -402,7 +402,9 @@ class FreeOrderProvider(BasePaymentProvider):
pass
def payment_is_valid_session(self, request: HttpRequest) -> bool:
return True
return CartPosition.objects.current.filter(
Q(session=request.session.session_key) & Q(event=request.event)
).aggregate(sum=Sum('price'))['sum'] == 0
@property
def verbose_name(self) -> str:

View File

@@ -0,0 +1,335 @@
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.urlresolvers import reverse
from django.core.validators import EmailValidator
from django.db.models import Q, Sum
from django.http import HttpResponseNotAllowed
from django.shortcuts import redirect
from django.utils import translation
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.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.questions import QuestionsViewMixin
class BaseCheckoutFlowStep:
def __init__(self, event):
self.event = event
self.request = None
@property
def identifier(self):
raise NotImplementedError()
@property
def priority(self):
return 100
def is_applicable(self, request):
return True
def is_completed(self, request, warn=False):
raise NotImplementedError()
def get_next_applicable(self, request):
if self._next:
if not self._next.is_applicable(request):
return self._next.get_next_applicable(request)
return self._next
def get_prev_applicable(self, request):
if self._previous:
if not self._previous.is_applicable(request):
return self._previous.get_prev_applicable(request)
return self._previous
def get(self, request):
return HttpResponseNotAllowed([])
def post(self, request):
return HttpResponseNotAllowed([])
def get_step_url(self):
return reverse(
'presale:event.checkout',
kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
'step': self.identifier
}
)
def get_prev_url(self, request):
prev = self.get_prev_applicable(request)
if not prev:
return reverse('presale:event.index', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
})
else:
return prev.get_step_url()
def get_next_url(self, request):
n = self.get_next_applicable(request)
if not n:
return reverse('presale:event.checkout.confirm', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
})
else:
return n.get_step_url()
def get_checkout_flow(event):
flow = list([step(event) for step in DEFAULT_FLOW])
for receiver, response in checkout_flow_steps.send(event):
step = response(event=event)
if step.priority > 1000:
raise ValueError('Plugins are not allowed to define a priority greater than 1000')
flow.append(step)
# Sort by priority
flow.sort(key=lambda p: p.priority)
# Create a double-linked-list for esasy forwards/backwards traversal
last = None
for step in flow:
step._previous = last
if last:
last._next = step
last = step
return flow
class TemplateFlowStep(TemplateResponseMixin, BaseCheckoutFlowStep):
template_name = ""
def get_context_data(self, **kwargs):
kwargs.setdefault('step', self)
kwargs.setdefault('event', self.event)
kwargs.setdefault('prev_url', self.get_prev_url(self.request))
return kwargs
def render(self, **kwargs):
context = self.get_context_data(**kwargs)
return self.render_to_response(context)
def get(self, request):
self.request = request
return self.render()
def post(self, request):
self.request = request
return self.render()
def is_completed(self, request, warn=False):
raise NotImplementedError()
@property
def identifier(self):
raise NotImplementedError()
class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
priority = 50
identifier = "questions"
template_name = "pretixpresale/event/checkout_questions.html"
def is_applicable(self, request):
return True
@cached_property
def contact_form(self):
return ContactForm(data=self.request.POST if self.request.method == "POST" else None,
initial={
'email': self.request.session.get('email', '')
})
def post(self, request):
self.request = request
failed = not self.save() or not self.contact_form.is_valid()
if failed:
messages.error(request,
_("We had difficulties processing your input. Please review the errors below."))
return self.render()
request.session['email'] = self.contact_form.cleaned_data['email']
return redirect(self.get_next_url(request))
def is_completed(self, request, warn=False):
self.request = request
try:
emailval = EmailValidator()
if 'email' not in request.session:
if warn:
messages.warning(request, _('Please enter a valid e-mail address.'))
return False
emailval(request.session.get('email'))
except ValidationError:
if warn:
messages.warning(request, _('Please enter a valid e-mail address.'))
return False
for cp in self.positions:
answ = {
aw.question_id: aw.answer for aw in cp.answers.all()
}
for q in cp.item.questions.all():
if q.required and q.identity not in answ:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.admission and self.request.event.settings.get('attendee_names_required', as_type=bool) \
and cp.attendee_name is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
return True
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['forms'] = self.forms
ctx['contact_form'] = self.contact_form
return ctx
class PaymentStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
priority = 200
identifier = "payment"
template_name = "pretixpresale/event/checkout_payment.html"
@cached_property
def _total_order_value(self):
return CartPosition.objects.current.filter(
Q(session=self.request.session.session_key) & Q(event=self.request.event)
).aggregate(sum=Sum('price'))['sum']
@cached_property
def provider_forms(self):
providers = []
responses = register_payment_providers.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if not provider.is_enabled or not provider.is_allowed(self.request):
continue
fee = provider.calculate_fee(self._total_order_value)
providers.append({
'provider': provider,
'fee': fee,
'form': provider.payment_form_render(self.request)
})
return providers
def post(self, request):
self.request = request
for p in self.provider_forms:
if p['provider'].identifier == request.POST.get('payment', ''):
request.session['payment'] = p['provider'].identifier
resp = p['provider'].checkout_prepare(
request, self.get_cart(payment_fee=p['provider'].calculate_fee(self._total_order_value)))
if isinstance(resp, str):
return redirect(resp)
elif resp is True:
return redirect(self.get_next_url(request))
else:
return self.render()
messages.error(self.request, _("Please select a payment method."))
return self.render()
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['providers'] = self.provider_forms
ctx['selected'] = self.request.POST.get('payment', self.request.session.get('payment', ''))
return ctx
@cached_property
def payment_provider(self):
responses = register_payment_providers.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.identifier == self.request.session['payment']:
return provider
def is_completed(self, request, warn=False):
self.request = request
if 'payment' not in request.session or not self.payment_provider:
if warn:
messages.error(request, _('The payment information you entered was incomplete.'))
return False
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):
if warn:
messages.error(request, _('The payment information you entered was incomplete.'))
return False
return True
def is_applicable(self, request):
self.request = request
if self._total_order_value == 0:
request.session['payment'] = 'free'
return False
return True
class ConfirmStep(CartMixin, TemplateFlowStep):
priority = 1001
identifier = "confirm"
template_name = "pretixpresale/event/checkout_confirm.html"
def is_applicable(self, request):
return True
def is_completed(self, request, warn=False):
pass
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart(answers=True)
ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request)
ctx['payment_provider'] = self.payment_provider
return ctx
@cached_property
def payment_provider(self):
responses = register_payment_providers.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.identifier == self.request.session['payment']:
return provider
def 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))
def get_order_url(self, order):
return reverse('presale:event.order', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'order': order.code,
'secret': order.secret
}) + '?thanks=yes'
DEFAULT_FLOW = (
QuestionsStep,
PaymentStep,
ConfirmStep
)

View File

@@ -4,7 +4,7 @@ from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Question
class GuestForm(forms.Form):
class ContactForm(forms.Form):
email = forms.EmailField(label=_('E-mail'))

View File

@@ -6,3 +6,8 @@ This signal is sent out to include code into the HTML <head> tag
html_head = EventPluginSignal(
providing_args=["request"]
)
"""
This signal is sent out to retrieve pages for the checkout flow
"""
checkout_flow_steps = EventPluginSignal()

View File

@@ -10,7 +10,8 @@
<div class="panel panel-primary cart">
<div class="panel-heading">
<div class="pull-right">
<a href="{% url "presale:event.index" organizer=request.event.organizer.slug event=request.event.slug %}">
<a href="
{% url "presale:event.index" organizer=request.event.organizer.slug event=request.event.slug %}">
<span class="fa fa-edit"></span>
{% trans "Modify" %}
</a>
@@ -39,12 +40,12 @@
<div class="panel panel-primary">
<div class="panel-heading">
{% if payment_provider.identifier != "free" %}
<div class="pull-right">
<a href="{% url "presale:event.checkout.payment" organizer=request.event.organizer.slug event=request.event.slug %}">
<span class="fa fa-edit"></span>
{% trans "Modify" %}
</a>
</div>
<div class="pull-right">
<a href="{% url "presale:event.checkout" step="payment" organizer=request.event.organizer.slug event=request.event.slug %}">
<span class="fa fa-edit"></span>
{% trans "Modify" %}
</a>
</div>
{% endif %}
<h3 class="panel-title">
{% trans "Payment" %}
@@ -54,20 +55,41 @@
{{ payment }}
</div>
</div>
</div>
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{{ view.get_previous_url }}">
{% trans "Go back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Place binding order" %}
</button>
<div class="row-fluid">
<div class="panel panel-primary panel-contact">
<div class="panel-heading">
<div class="pull-right">
<a href="{% url "presale:event.checkout" step="questions" organizer=request.event.organizer.slug event=request.event.slug %}">
<span class="fa fa-edit"></span>
{% trans "Modify" %}
</a>
</div>
<h3 class="panel-title">
{% trans "Contact information" %}
</h3>
</div>
<div class="panel-body">
<dl class="dl-horizontal">
<dt>{% trans "E-mail address" %}</dt>
<dd>{{ request.session.email }}</dd>
</dl>
</div>
</div>
</div>
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{{ prev_url }}">
{% trans "Go back" %}
</a>
</div>
<div class="col-md-4 col-md-offset-4">
<button class="btn btn-block btn-primary btn-lg" type="submit">
{% trans "Place binding order" %}
</button>
</div>
<div class="clearfix"></div>
</div>
<div class="clearfix"></div>
</div>
</form>
{% endblock %}

View File

@@ -34,7 +34,7 @@
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{{ view.get_previous_url }}">
href="{{ prev_url }}">
{% trans "Go back" %}
</a>
</div>

View File

@@ -8,6 +8,20 @@
<form class="form-horizontal" method="post">
{% csrf_token %}
<div class="panel-group" id="questions_accordion">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">
<a data-toggle="collapse" href="#contact" data-parent="#questions_accordion">
<strong>{% trans "Contact information" %}</strong>
</a>
</h4>
</div>
<div id="contact" class="panel-collapse collapsed in">
<div class="panel-body">
{% bootstrap_form contact_form layout="horizontal" %}
</div>
</div>
</div>
{% for form in forms %}
<div class="panel panel-default">
<div class="panel-heading">
@@ -22,7 +36,7 @@
</h4>
</div>
<div id="cp{{ form.pos.identity }}"
class="panel-collapse collapsed {% if forloop.counter0 == 0 %}in{% endif %}">
class="panel-collapse collapsed in">
<div class="panel-body">
{% bootstrap_form form layout="horizontal" %}
</div>
@@ -33,7 +47,7 @@
<div class="row checkout-button-row">
<div class="col-md-4">
<a class="btn btn-block btn-default btn-lg"
href="{{ view.get_index_url }}">
href="{{ prev_url }}">
{% trans "Go back" %}
</a>
</div>

View File

@@ -11,11 +11,9 @@ urlpatterns = [
url(r'^$', pretix.presale.views.event.EventIndex.as_view(), name='event.index'),
url(r'^cart/add$', pretix.presale.views.cart.CartAdd.as_view(), name='event.cart.add'),
url(r'^cart/remove$', pretix.presale.views.cart.CartRemove.as_view(), name='event.cart.remove'),
url(r'^checkout$', pretix.presale.views.checkout.CheckoutStart.as_view(), name='event.checkout.start'),
url(r'^checkout/payment$', pretix.presale.views.checkout.PaymentDetails.as_view(),
name='event.checkout.payment'),
url(r'^checkout/confirm$', pretix.presale.views.checkout.OrderConfirm.as_view(),
name='event.checkout.confirm'),
url(r'^checkout/$', pretix.presale.views.checkout.CheckoutView.as_view(), name='event.checkout.start'),
url(r'^checkout/(?P<step>[^/]+)/$', pretix.presale.views.checkout.CheckoutView.as_view(),
name='event.checkout'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/$', pretix.presale.views.order.OrderDetails.as_view(),
name='event.order'),
url(r'^order/(?P<order>[^/]+)/(?P<secret>[A-Za-z0-9]+)/cancel$',

View File

@@ -32,7 +32,7 @@ class LoginRequiredMixin:
return login_required(view)
class CartDisplayMixin:
class CartMixin:
@cached_property
def positions(self):
"""

View File

@@ -1,275 +1,37 @@
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.db.models import Q, Sum
from django.http import HttpRequest
from django.http import Http404
from django.shortcuts import redirect
from django.utils import translation
from django.utils.functional import cached_property
from django.utils.translation import ugettext_lazy as _
from django.views.generic import TemplateView
from django.views.generic import View
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
from pretix.base.services.orders import OrderError, perform_order
from pretix.base.signals import register_payment_providers
from pretix.presale.forms.checkout import QuestionsForm
from pretix.presale.views import CartDisplayMixin, EventViewMixin
from pretix.presale.checkoutflow import get_checkout_flow
from pretix.presale.views import CartMixin
class CheckoutView(TemplateView):
def get_payment_url(self):
return reverse('presale:event.checkout.payment', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
def get_confirm_url(self):
return reverse('presale:event.checkout.confirm', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
def get_questions_url(self):
return reverse('presale:event.checkout.start', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
def get_index_url(self):
return reverse('presale:event.index', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug
})
def get_order_url(self, order):
return reverse('presale:event.order', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
'order': order.code,
'secret': order.secret
}) + '?thanks=yes'
class QuestionsViewMixin:
@cached_property
def forms(self):
"""
A list of forms with one form for each cart cart position that has questions
the user can answer. All forms have a custom prefix, so that they can all be
submitted at once.
"""
formlist = []
for cr in self.positions:
cartpos = cr if isinstance(cr, CartPosition) else None
orderpos = cr if isinstance(cr, OrderPosition) else None
form = QuestionsForm(event=self.request.event,
prefix=cr.identity,
cartpos=cartpos,
orderpos=orderpos,
data=(self.request.POST if self.request.method == 'POST' else None))
form.pos = cartpos or orderpos
if len(form.fields) > 0:
formlist.append(form)
return formlist
def save(self):
failed = False
for form in self.forms:
# Every form represents a CartPosition or OrderPosition with questions attached
if not form.is_valid():
failed = True
else:
# This form was correctly filled, so we store the data as
# answers to the questions / in the CartPosition object
for k, v in form.cleaned_data.items():
if k == 'attendee_name':
form.pos = form.pos.clone()
form.pos.attendee_name = v if v != '' else None
form.pos.save()
elif k.startswith('question_') and v is not None:
field = form.fields[k]
if hasattr(field, 'answer'):
# We already have a cached answer object, so we don't
# have to create a new one
if v == '':
field.answer.delete()
else:
field.answer = field.answer.clone()
field.answer.answer = v
field.answer.save()
elif v != '':
QuestionAnswer.objects.create(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question,
answer=v
)
return not failed
class CheckoutStart(EventViewMixin, CartDisplayMixin, QuestionsViewMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_questions.html"
def post(self, *args, **kwargs):
failed = not self.save()
if failed:
messages.error(self.request,
_("We had difficulties processing your input. Please review the errors below."))
return self.get(*args, **kwargs)
return redirect(self.get_payment_url())
def get(self, *args, **kwargs):
class CheckoutView(CartMixin, View):
def dispatch(self, request, *args, **kwargs):
self.request = request
if not self.positions:
messages.error(self.request,
_("Your cart is empty"))
return redirect(self.get_index_url())
messages.error(request, _("Your cart is empty"))
return redirect(reverse('presale:event.index', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
}))
if not self.forms:
# Nothing to do here
if self.request.GET.get('back', 'false') == 'true':
return redirect(self.get_index_url())
return redirect(self.get_payment_url())
return super().get(*args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['forms'] = self.forms
return ctx
class PaymentDetails(EventViewMixin, CartDisplayMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_payment.html"
@cached_property
def _total_order_value(self):
return CartPosition.objects.current.filter(
Q(session=self.request.session.session_key) & Q(event=self.request.event)
).aggregate(sum=Sum('price'))['sum']
@cached_property
def provider_forms(self):
providers = []
responses = register_payment_providers.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if not provider.is_enabled or not provider.is_allowed(self.request):
flow = get_checkout_flow(self.request.event)
for step in flow:
if not step.is_applicable(request):
continue
fee = provider.calculate_fee(self._total_order_value)
providers.append({
'provider': provider,
'fee': fee,
'form': provider.payment_form_render(self.request),
})
return providers
def post(self, request, *args, **kwargs):
for p in self.provider_forms:
if p['provider'].identifier == request.POST.get('payment', ''):
request.session['payment'] = p['provider'].identifier
resp = p['provider'].checkout_prepare(
request, self.get_cart(payment_fee=p['provider'].calculate_fee(self._total_order_value)))
if isinstance(resp, str):
return redirect(resp)
elif resp is True:
return redirect(self.get_confirm_url())
if 'step' not in kwargs:
return redirect(step.get_step_url())
is_selected = (step.identifier == kwargs.get('step', ''))
if not is_selected and not step.is_completed(request, warn=not is_selected):
return redirect(step.get_step_url())
if is_selected:
if request.method.lower() in self.http_method_names:
handler = getattr(step, request.method.lower(), self.http_method_not_allowed)
else:
return self.get(request, *args, **kwargs)
messages.error(self.request, _("Please select a payment method."))
return self.get(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if self._total_order_value == 0:
request.session['payment'] = 'free'
return redirect(self.get_confirm_url())
return super().get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['providers'] = self.provider_forms
ctx['selected'] = self.request.POST.get('payment', self.request.session.get('payment', ''))
return ctx
def get_previous_url(self):
return self.get_questions_url() + "?back=true"
class OrderConfirm(EventViewMixin, CartDisplayMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_confirm.html"
def __init__(self, *args, **kwargs):
self.msg_some_unavailable = False
super().__init__(*args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart(answers=True)
ctx['payment'] = self.payment_provider.checkout_confirm_render(self.request)
ctx['payment_provider'] = self.payment_provider
return ctx
@cached_property
def payment_provider(self):
responses = register_payment_providers.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.identifier == self.request.session['payment']:
return provider
def check_process(self, request):
if len(self.positions) == 0:
messages.warning(request, _('Your cart is empty.'))
return redirect(self.get_index_url())
if 'payment' not in request.session or not self.payment_provider:
messages.error(request, _('The payment information you entered was incomplete.'))
return redirect(self.get_payment_url())
if not self.payment_provider.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())
for cp in self.positions:
answ = {
aw.question_id: aw.answer for aw in cp.answers.all()
}
for q in cp.item.questions.all():
if q.required and q.identity not in answ:
messages.warning(request, _('Please fill in answers to all required questions.'))
return redirect(self.get_questions_url())
if cp.item.admission and self.request.event.settings.get('attendee_names_required', as_type=bool) \
and cp.attendee_name is None:
messages.warning(request, _('Please fill in answers to all required questions.'))
return redirect(self.get_questions_url())
def get(self, request, *args, **kwargs):
self.request = request
return self.check_process(request) or super().get(request, *args, **kwargs)
def error_message(self, msg, important=False):
if not self.msg_some_unavailable or important:
self.msg_some_unavailable = True
messages.error(self.request, msg)
def post(self, request, *args, **kwargs):
self.request = request
return self.check_process(request) or self.perform_order(request)
def perform_order(self, request: HttpRequest):
try:
order = perform_order(self.request.event, self.payment_provider, self.positions,
email=request.session.get('guest_email', None),
locale=translation.get_language())
except OrderError as e:
messages.error(request, str(e))
return redirect(self.get_confirm_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))
def get_previous_url(self):
if self.payment_provider.identifier != "free":
return self.get_payment_url()
else:
return self.get_questions_url() + "?back=true"
handler = self.http_method_not_allowed
return handler(request)
raise Http404()

View File

@@ -1,10 +1,10 @@
from django.db.models import Count
from django.views.generic import TemplateView
from pretix.presale.views import CartDisplayMixin, EventViewMixin
from pretix.presale.views import CartMixin, EventViewMixin
class EventIndex(EventViewMixin, CartDisplayMixin, TemplateView):
class EventIndex(EventViewMixin, CartMixin, TemplateView):
template_name = "pretixpresale/event/index.html"
def get_context_data(self, **kwargs):

View File

@@ -14,8 +14,8 @@ from pretix.base.services.tickets import generate
from pretix.base.signals import (
register_payment_providers, register_ticket_outputs,
)
from pretix.presale.views import CartDisplayMixin, EventViewMixin
from pretix.presale.views.checkout import QuestionsViewMixin
from pretix.presale.views import CartMixin, EventViewMixin
from pretix.presale.views.questions import QuestionsViewMixin
class OrderDetailMixin:
@@ -44,7 +44,7 @@ class OrderDetailMixin:
})
class OrderDetails(EventViewMixin, OrderDetailMixin, CartDisplayMixin, TemplateView):
class OrderDetails(EventViewMixin, OrderDetailMixin, CartMixin, TemplateView):
template_name = "pretixpresale/event/order.html"
def get(self, request, *args, **kwargs):

View File

@@ -0,0 +1,61 @@
from django.utils.functional import cached_property
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
from pretix.presale.forms.checkout import QuestionsForm
class QuestionsViewMixin:
@cached_property
def forms(self):
"""
A list of forms with one form for each cart cart position that has questions
the user can answer. All forms have a custom prefix, so that they can all be
submitted at once.
"""
formlist = []
for cr in self.positions:
cartpos = cr if isinstance(cr, CartPosition) else None
orderpos = cr if isinstance(cr, OrderPosition) else None
form = QuestionsForm(event=self.request.event,
prefix=cr.identity,
cartpos=cartpos,
orderpos=orderpos,
data=(self.request.POST if self.request.method == 'POST' else None))
form.pos = cartpos or orderpos
if len(form.fields) > 0:
formlist.append(form)
return formlist
def save(self):
failed = False
for form in self.forms:
# Every form represents a CartPosition or OrderPosition with questions attached
if not form.is_valid():
failed = True
else:
# This form was correctly filled, so we store the data as
# answers to the questions / in the CartPosition object
for k, v in form.cleaned_data.items():
if k == 'attendee_name':
form.pos = form.pos.clone()
form.pos.attendee_name = v if v != '' else None
form.pos.save()
elif k.startswith('question_') and v is not None:
field = form.fields[k]
if hasattr(field, 'answer'):
# We already have a cached answer object, so we don't
# have to create a new one
if v == '':
field.answer.delete()
else:
field.answer = field.answer.clone()
field.answer.answer = v
field.answer.save()
elif v != '':
QuestionAnswer.objects.create(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
question=field.question,
answer=v
)
return not failed

View File

@@ -67,6 +67,9 @@
}
}
}
.panel-contact dl {
margin-bottom: 0;
}
.panel-primary .panel-heading a {
color: white;
}

View File

@@ -31,25 +31,13 @@ class CheckoutTestCase(TestCase):
self.client.get('/%s/%s/' % (self.orga.slug, self.event.slug))
self.session_key = self.client.cookies.get(settings.SESSION_COOKIE_NAME).value
self._set_session('email', 'admin@localhost')
def test_empty_cart(self):
response = self.client.get('/%s/%s/checkout' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.get('/%s/%s/checkout/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_no_questions(self):
CartPosition.objects.create(
event=self.event, session=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
CartPosition.objects.create(
event=self.event, session=self.session_key, item=self.ticket,
price=20, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_questions(self):
q1 = Question.objects.create(
event=self.event, question='Age', type=Question.TYPE_NUMBER,
@@ -69,7 +57,7 @@ class CheckoutTestCase(TestCase):
event=self.event, session=self.session_key, item=self.ticket,
price=20, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select('input[name=%s-question_%s]' % (cr1.identity, q1.identity))), 1)
@@ -78,23 +66,25 @@ class CheckoutTestCase(TestCase):
self.assertEqual(len(doc.select('input[name=%s-question_%s]' % (cr2.identity, q2.identity))), 1)
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout' % (self.orga.slug, self.event.slug), {
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr1.identity, q1.identity): '42',
'%s-question_%s' % (cr2.identity, q1.identity): '',
'%s-question_%s' % (cr1.identity, q2.identity): 'Internet',
'%s-question_%s' % (cr2.identity, q2.identity): '',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout' % (self.orga.slug, self.event.slug), {
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-question_%s' % (cr1.identity, q1.identity): '42',
'%s-question_%s' % (cr2.identity, q1.identity): '23',
'%s-question_%s' % (cr1.identity, q2.identity): 'Internet',
'%s-question_%s' % (cr2.identity, q2.identity): '',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment' % (self.orga.slug, self.event.slug),
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1 = CartPosition.objects.current.get(identity=cr1.identity)
@@ -111,22 +101,24 @@ class CheckoutTestCase(TestCase):
event=self.event, session=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select('input[name=%s-attendee_name]' % cr1.identity)), 1)
# Not all required fields filled out, expect failure
response = self.client.post('/%s/%s/checkout' % (self.orga.slug, self.event.slug), {
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_name' % cr1.identity: '',
'email': 'admin@localhost'
}, follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertGreaterEqual(len(doc.select('.has-error')), 1)
# Corrected request
response = self.client.post('/%s/%s/checkout' % (self.orga.slug, self.event.slug), {
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_name' % cr1.identity: 'Peter',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment' % (self.orga.slug, self.event.slug),
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1 = CartPosition.objects.current.get(identity=cr1.identity)
@@ -139,15 +131,16 @@ class CheckoutTestCase(TestCase):
event=self.event, session=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.get('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select('input[name=%s-attendee_name]' % cr1.identity)), 1)
# Not all fields filled out, expect success
response = self.client.post('/%s/%s/checkout' % (self.orga.slug, self.event.slug), {
response = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
'%s-attendee_name' % cr1.identity: '',
'email': 'admin@localhost'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment' % (self.orga.slug, self.event.slug),
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1 = CartPosition.objects.current.get(identity=cr1.identity)
@@ -161,17 +154,17 @@ class CheckoutTestCase(TestCase):
event=self.event, session=self.session_key, item=self.ticket,
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/payment' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.get('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select('input[name=payment]')), 2)
response = self.client.post('/%s/%s/checkout/payment' % (self.orga.slug, self.event.slug), {
response = self.client.post('/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug), {
'payment': 'banktransfer'
}, follow=True)
self.assertRedirects(response, '/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug),
self.assertRedirects(response, '/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def test_premature_confirm(self):
response = self.client.get('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/' % (self.orga.slug, self.event.slug),
target_status_code=200)
@@ -182,8 +175,8 @@ class CheckoutTestCase(TestCase):
price=23, expires=now() + timedelta(minutes=10)
)
response = self.client.get('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment' % (self.orga.slug, self.event.slug),
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout/payment/' % (self.orga.slug, self.event.slug),
target_status_code=200)
self._set_session('payment', 'banktransfer')
@@ -191,8 +184,8 @@ class CheckoutTestCase(TestCase):
self.event.settings.set('attendee_names_asked', True)
self.event.settings.set('attendee_names_required', True)
response = self.client.get('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout' % (self.orga.slug, self.event.slug),
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200)
cr1 = cr1.clone()
@@ -204,16 +197,21 @@ class CheckoutTestCase(TestCase):
)
self.ticket.questions.add(q1)
response = self.client.get('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout' % (self.orga.slug, self.event.slug),
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200)
q1 = q1.clone()
q1.required = False
q1.save()
response = self.client.get('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertEqual(response.status_code, 200)
self._set_session('email', 'invalid')
response = self.client.get('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
self.assertRedirects(response, '/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug),
target_status_code=200)
def _set_session(self, key, value):
session = self.client.session
session[key] = value
@@ -226,7 +224,7 @@ class CheckoutTestCase(TestCase):
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.current.filter(identity=cr1.identity).exists())
@@ -240,7 +238,7 @@ class CheckoutTestCase(TestCase):
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select(".thank-you")), 1)
self.assertFalse(CartPosition.objects.current.filter(identity=cr1.identity).exists())
@@ -257,7 +255,7 @@ class CheckoutTestCase(TestCase):
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select(".alert-danger")), 1)
cr1 = CartPosition.objects.current.get(identity=cr1.identity)
@@ -276,7 +274,7 @@ class CheckoutTestCase(TestCase):
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select(".alert-danger")), 1)
self.assertEqual(CartPosition.objects.current.filter(session=self.session_key).count(), 1)
@@ -291,9 +289,9 @@ class CheckoutTestCase(TestCase):
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select(".alert-danger")), 1)
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
self.assertFalse(CartPosition.objects.current.filter(identity=cr1.identity).exists())
def test_confirm_expired_unavailable(self):
@@ -305,19 +303,20 @@ class CheckoutTestCase(TestCase):
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select(".alert-danger")), 1)
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
self.assertFalse(CartPosition.objects.current.filter(identity=cr1.identity).exists())
def test_confirm_completely_unavailable(self):
self.quota_tickets.items.remove(self.ticket)
CartPosition.objects.create(
cr1 = CartPosition.objects.create(
event=self.event, session=self.session_key, item=self.ticket,
price=23, expires=now() - timedelta(minutes=10)
)
self._set_session('payment', 'banktransfer')
response = self.client.post('/%s/%s/checkout/confirm' % (self.orga.slug, self.event.slug), follow=True)
response = self.client.post('/%s/%s/checkout/confirm/' % (self.orga.slug, self.event.slug), follow=True)
doc = BeautifulSoup(response.rendered_content)
self.assertEqual(len(doc.select(".alert-danger")), 1)
self.assertGreaterEqual(len(doc.select(".alert-danger")), 1)
self.assertFalse(CartPosition.objects.current.filter(identity=cr1.identity).exists())