Merge branch 'master' of github.com:pretix/pretix

This commit is contained in:
Raphael Michel
2015-03-08 14:00:36 +01:00
19 changed files with 553 additions and 61 deletions

View File

@@ -44,6 +44,9 @@
border-top: 1px solid @table-border-color;
}
}
.panel-primary .panel-heading a {
color: white;
}
.checkout-button-row {
padding: 15px 0;
}

View File

@@ -0,0 +1,66 @@
{% extends "pretixpresale/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Confirm order" %}{% endblock %}
{% block content %}
<h2>{% trans "Confirm order" %}</h2>
<p>{% trans "Please review the details below and confirm your order." %}</p>
<form method="post">
{% csrf_token %}
<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 %}">{% trans "Modify" %}</a>
</div>
<h3 class="panel-title">
{% trans "Your cart" %}
</h3>
</div>
<div class="panel-body">
{% include "pretixpresale/event/fragment_cart.html" with cart=cart event=request.event editable=False %}
<div class="row-fluid">
<div class="col-md-6 col-xs-12">
{% if cart.minutes_left > 0 %}
<em>{% blocktrans trimmed with minutes=cart.minutes_left %}
The items in your cart are reserved for you for {{ minutes }} minutes.
{% endblocktrans %}</em>
{% else %}
<em>{% trans "The items in your cart are no longer reserved for you." %}</em>
{% endif %}
</div>
<div class="clearfix"></div>
</div>
</div>
</div>
{# TODO: Question answers #}
<div class="row-fluid">
<div class="panel panel-primary">
<div class="panel-heading">
<div class="pull-right">
<a href="{% url "presale:event.checkout.payment" organizer=request.event.organizer.slug event=request.event.slug %}">{% trans "Modify" %}</a>
</div>
<h3 class="panel-title">
{% trans "Payment" %}
</h3>
</div>
<div class="panel-body">
{{ 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_payment_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>
</form>
{% endblock %}

View File

@@ -16,14 +16,16 @@
<strong class="pull-right">+ {{ p.fee|floatformat:2 }} {{ event.currency }}</strong>
<input type="radio" name="payment" value="{{ p.provider.identifier }}"
data-parent="#payment_accordion"
required="True"
{% if selected == p.provider.identifier %}checked="checked"{% endif %}
data-toggle="radiocollapse" data-target="#payment_{{ p.provider.identifier }}" />
<strong>{{ p.provider.verbose_name }}</strong>
</label>
</h4>
</div>
<div id="payment_{{ p.provider.identifier }}" class="panel-collapse collapse">
<div id="payment_{{ p.provider.identifier }}"
class="panel-collapse collapse {% if selected == p.provider.identifier %}in{% endif %}">
<div class="panel-body form-horizontal">
{{ p.form }}
</div>
</div>
</div>
@@ -32,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="{{ view.get_questions_url }}">
{% trans "Go back" %}
</a>
</div>
@@ -44,4 +46,4 @@
<div class="clearfix"></div>
</div>
</form>
{% endblock %}
{% endblock %}

View File

@@ -0,0 +1,2 @@
{% load bootstrap3 %}
{% bootstrap_form form layout='horizontal' %}

View File

@@ -33,7 +33,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="{{ view.get_index_url }}">
{% trans "Go back" %}
</a>
</div>
@@ -45,4 +45,4 @@
<div class="clearfix"></div>
</div>
</form>
{% endblock %}
{% endblock %}

View File

@@ -52,6 +52,18 @@
<div class="clearfix"></div>
</div>
{% endfor %}
{% if cart.payment_fee %}
{# TODO: Tax rate? #}
<div class="row-fluid cart-row">
<div class="col-md-4 col-xs-6">
<strong>{% trans "Payment method fee" %}</strong>
</div>
<div class="col-md-3 col-xs-6 col-md-offset-5 price">
<strong>{{ event.currency }} {{ cart.payment_fee|floatformat:2 }}</strong>
</div>
<div class="clearfix"></div>
</div>
{% endif %}
<div class="row-fluid cart-row total">
<div class="col-md-4 col-xs-6">
<strong>{% trans "Total" %}</strong>

View File

@@ -16,6 +16,10 @@ urlpatterns = patterns(
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'^order/(?P<order>[^/]+)/$', pretix.presale.views.checkout.OrderConfirm.as_view(),
name='event.order'),
url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'),
)
)),

View File

@@ -5,9 +5,11 @@ from django.contrib.auth.views import redirect_to_login
from django.core.urlresolvers import reverse
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.timezone import now
from pretix.base.models import CartPosition
from pretix.base.signals import register_payment_providers
class EventLoginRequiredMixin:
@@ -34,6 +36,22 @@ class EventLoginRequiredMixin:
class CartDisplayMixin:
@cached_property
def cartpos(self):
"""
A list of this users cart position
"""
return list(CartPosition.objects.current.filter(
Q(user=self.request.user) & Q(event=self.request.event)
).order_by(
'item', 'variation'
).select_related(
'item', 'variation'
).prefetch_related(
'variation__values', 'variation__values__prop',
'item__questions', 'answers'
))
def get_cart(self):
cartpos = CartPosition.objects.current.filter(
Q(user=self.request.user) & Q(event=self.request.event)
@@ -59,10 +77,21 @@ class CartDisplayMixin:
group.total = group.count * group.price
positions.append(group)
total = sum(p.total for p in positions)
payment_fee = 0
if 'payment' in self.request.session:
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']:
payment_fee = provider.calculate_fee(total)
return {
'positions': positions,
'raw': cartpos,
'total': sum(p.total for p in positions),
'total': total + payment_fee,
'payment_fee': payment_fee,
'minutes_left': (
max(min(p.expires for p in positions) - now(), timedelta()).seconds // 60
if positions else 0

View File

@@ -136,7 +136,7 @@ class CartAdd(EventViewMixin, CartActionMixin, View):
).update(expires=now() + timedelta(minutes=30))
# For items that are already expired, we have to delete and re-add them, as they might
# be no longer available. Sorry!
# be no longer available or prices might have changed. Sorry!
for cp in CartPosition.objects.current.filter(
Q(user=self.request.user) & Q(event=self.request.event) & Q(expires__lte=now())):
items = self._re_add_position(items, cp)
@@ -220,7 +220,7 @@ class CartAdd(EventViewMixin, CartActionMixin, View):
item=item,
variation=variation,
price=price,
expires=now() + timedelta(minutes=30)
expires=now() + timedelta(minutes=self.request.event.settings.get('reservation_time', as_type=int))
)
except Quota.LockTimeoutException:
# Is raised when there are too many threads asking for quota locks and we were

View File

@@ -1,12 +1,15 @@
from datetime import timedelta
from django.contrib import messages
from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Q, Sum
from django import forms
from django.shortcuts import redirect
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.views.generic import View, TemplateView
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CartPosition, Question, QuestionAnswer
from pretix.base.models import CartPosition, Question, QuestionAnswer, Quota, Order, OrderPosition
from pretix.base.signals import register_payment_providers
from pretix.presale.views import EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin
@@ -75,27 +78,43 @@ class QuestionsForm(forms.Form):
self.fields['question_%s' % q.identity] = field
class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView):
template_name = "pretixpresale/event/checkout_questions.html"
class CheckoutView(TemplateView):
def get_success_url(self):
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_url(self):
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_previous_url(self):
def get_index_url(self):
return reverse('presale:event.index', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.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,
})
class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_questions.html"
@cached_property
def forms(self):
"""
@@ -114,22 +133,6 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T
formlist.append(form)
return formlist
@cached_property
def cartpos(self):
"""
A list of this users cart position
"""
return list(CartPosition.objects.current.filter(
Q(user=self.request.user) & Q(event=self.request.event)
).order_by(
'item', 'variation'
).select_related(
'item', 'variation'
).prefetch_related(
'variation__values', 'variation__values__prop',
'item__questions', 'answers'
))
def post(self, *args, **kwargs):
failed = False
for form in self.forms:
@@ -162,17 +165,17 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T
messages.error(self.request,
_("We had difficulties processing your input. Please review the errors below."))
return self.get(*args, **kwargs)
return redirect(self.get_success_url())
return redirect(self.get_payment_url())
def get(self, *args, **kwargs):
if not self.cartpos:
messages.error(self.request,
_("Your cart is empty"))
return redirect(self.get_previous_url())
return redirect(self.get_index_url())
if not self.forms:
# Nothing to do here
return redirect(self.get_success_url())
return redirect(self.get_payment_url())
return super().get(*args, **kwargs)
@@ -182,46 +185,203 @@ class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, T
return ctx
class PaymentDetails(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, TemplateView):
class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_payment.html"
def get_success_url(self):
return reverse('presale:event.index', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
def get_url(self):
return reverse('presale:event.checkout.payment', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
def get_previous_url(self):
return reverse('presale:event.checkout.start', kwargs={
'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug,
})
@cached_property
def _total_order_value(self):
return CartPosition.objects.current.filter(
Q(user=self.request.user) & Q(event=self.request.event)
).aggregate(sum=Sum('price'))['sum']
@cached_property
def provider_forms(self):
total = CartPosition.objects.current.filter(
Q(user=self.request.user) & Q(event=self.request.event)
).aggregate(sum=Sum('price'))['sum']
providers = []
responses = register_payment_providers.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if not provider.is_enabled:
continue
fee = provider.calculate_fee(total)
fee = provider.calculate_fee(self._total_order_value)
providers.append({
'provider': provider,
'fee': fee,
'form': provider.checkout_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
total = self._total_order_value + p['provider'].calculate_fee(self._total_order_value)
resp = p['provider'].checkout_prepare(request, total)
if isinstance(resp, str):
return redirect(str)
elif resp is True:
return redirect(self.get_confirm_url())
else:
return self.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
class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_confirm.html"
error_messages = {
'unavailable': _('Some of the items you selected were no longer available. '
'Please see below for details.'),
'in_part': _('Some of the items you selected were no longer available in '
'the quantity you selected. Please see below for details.'),
'price_changed': _('The price of some of the items in your cart has changed in the '
'meantime. Please see below for details.'),
'busy': _('We were not able to process your request completely as the '
'server was too busy. Please try again.'),
'max_items': _("You cannot select more than %s items per order"),
}
def __init__(self, *args, **kwargs):
self.msg_some_unavailable = False
super().__init__(*args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart()
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 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.checkout_is_valid_session(request):
messages.error(request, _('The payment information you entered was incomplete.'))
return redirect(self.get_payment_url())
if len(self.cartpos) == 0:
messages.warning(request, _('Your cart is empty.'))
return redirect(self.get_index_url())
for cp in self.cartpos:
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())
def get(self, request, *args, **kwargs):
self.request = request
check = self.check_process(request)
if check:
return check
return super().get(request, *args, **kwargs)
def error_message(self, msg, important=False):
if not self.msg_some_unavailable or important:
self.msg_some_unavailable = True
messages.error(self.request, msg)
def post(self, request, *args, **kwargs):
self.request = request
check = self.check_process(request)
if check:
return
dt = now()
quotas_locked = set()
try:
cartpos = self.cartpos
for i, cp in enumerate(cartpos):
quotas = list(cp.item.quotas.all()) if cp.variation is None else list(cp.variation.quotas.all())
if cp.expires < dt:
price = cp.item.check_restrictions() if cp.variation is None else cp.variation.check_restrictions()
if price is False:
self.error_message(self.error_messages['unavailable'])
continue
if len(quotas) == 0:
self.error_message(self.error_messages['unavailable'])
continue
if price != cp.price:
cp = cp.clone()
cartpos[i] = cp
cp.price = price
cp.save()
self.error_message(self.error_messages['price_changed'])
continue
quota_ok = True
for quota in quotas:
# Lock the quota, so no other thread is allowed to perform sales covered by this
# quota while we're doing so.
if quota not in quotas_locked:
quota.lock()
quotas_locked.add(quota)
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK:
# This quota is sold out/currently unavailable, so do not sell this at all
self.error_message(self.error_messages['unavailable'])
quota_ok = False
break
if quota_ok:
cp = cp.clone()
cartpos[i] = cp
cp.expires = now() + timedelta(minutes=self.request.event.settings.get('reservation_time', as_type=int))
cp.save()
if not self.msg_some_unavailable: # Everything went well
with transaction.atomic():
total = sum([c.price for c in cartpos])
payment_fee = self.payment_provider.calculate_fee(total)
total += payment_fee
expires = [dt + timedelta(days=request.event.payment_term_days)]
if request.event.payment_term_last:
expires.append(request.event.payment_term_last)
order = Order.objects.create(
status=Order.STATUS_PENDING,
event=request.event,
user=request.user,
datetime=dt,
expires=min(expires),
total=total,
payment_fee=payment_fee,
payment_provider=self.payment_provider.identifier,
)
for cp in cartpos:
op = OrderPosition.objects.create(
order=order, item=cp.item, variation=cp.variation,
price=cp.price, attendee_name=cp.attendee_name
)
for answ in cp.answers.all():
answ = answ.clone()
answ.orderposition = op
answ.cartposition = None
answ.save()
cp.delete()
messages.success(request, _('Your order has been placed.'))
resp = self.payment_provider.checkout_perform(request, order)
if isinstance(resp, str):
return redirect(str)
else:
return redirect(self.get_order_url(order))
except Quota.LockTimeoutException:
# Is raised when there are too many threads asking for quota locks and we were
# unaible to get one
self.error_message(self.error_messages['busy'], important=True)
finally:
# Release the locks. This is important ;)
for quota in quotas_locked:
quota.release()
return redirect(self.get_confirm_url())