Submitting orders

This commit is contained in:
Raphael Michel
2015-03-08 11:20:17 +01:00
parent 62b82bc852
commit a08b43ad45
8 changed files with 237 additions and 15 deletions

View File

@@ -0,0 +1,24 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0014_auto_20150305_2310'),
]
operations = [
migrations.AddField(
model_name='order',
name='payment_provider',
field=models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider'),
),
migrations.AlterField(
model_name='order',
name='datetime',
field=models.DateTimeField(verbose_name='Date'),
),
]

View File

@@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import models, migrations
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0015_auto_20150308_0953'),
]
operations = [
migrations.AddField(
model_name='order',
name='code',
field=models.CharField(max_length=16, verbose_name='Order code', default=''),
preserve_default=False,
),
migrations.AddField(
model_name='order',
name='payment_fee',
field=models.DecimalField(max_digits=10, verbose_name='Payment method fee', decimal_places=2, default=0),
preserve_default=False,
),
]

View File

@@ -1,6 +1,7 @@
from itertools import product from itertools import product
import copy import copy
import uuid import uuid
import random
import time import time
from django.db import models from django.db import models
@@ -1219,8 +1220,9 @@ class Order(Versionable):
expiration date: If items run out of capacity, orders which are over expiration date: If items run out of capacity, orders which are over
their expiration date might be cancelled. their expiration date might be cancelled.
Important: An order holds its total monetary value, as an order is a An order -- like all objects -- has an ID, which is globally unique,
piece of 'history' and must not change due to a change in item prices. but also a code, which is shorter and easier to memorize, but only
unique among a single conference.
""" """
STATUS_PENDING = "n" STATUS_PENDING = "n"
@@ -1234,6 +1236,10 @@ class Order(Versionable):
(STATUS_CANCELLED, _("cancelled")), (STATUS_CANCELLED, _("cancelled")),
) )
code = models.CharField(
max_length=16,
verbose_name=_("Order code")
)
status = models.CharField( status = models.CharField(
max_length=3, max_length=3,
choices=STATUS_CHOICE, choices=STATUS_CHOICE,
@@ -1248,7 +1254,6 @@ class Order(Versionable):
verbose_name=_("User") verbose_name=_("User")
) )
datetime = models.DateTimeField( datetime = models.DateTimeField(
auto_now_add=True,
verbose_name=_("Date") verbose_name=_("Date")
) )
expires = models.DateTimeField( expires = models.DateTimeField(
@@ -1258,6 +1263,15 @@ class Order(Versionable):
verbose_name=_("Payment date"), verbose_name=_("Payment date"),
null=True, blank=True null=True, blank=True
) )
payment_provider = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_("Payment provider")
)
payment_fee = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Payment method fee")
)
payment_info = models.TextField( payment_info = models.TextField(
verbose_name=_("Payment information"), verbose_name=_("Payment information"),
null=True, blank=True null=True, blank=True
@@ -1271,6 +1285,30 @@ class Order(Versionable):
verbose_name = _("Order") verbose_name = _("Order")
verbose_name_plural = _("Orders") verbose_name_plural = _("Orders")
def str(self):
return self.full_code
@property
def full_code(self):
"""
A order code which is unique among all events of a single organizer,
built by contatenating the event slug and the order code.
"""
return self.event.slug.upper() + self.code
def save(self, *args, **kwargs):
if not self.code:
self.assign_code()
super().save(*args, **kwargs)
def assign_code(self):
charset = list('ABCDEFGHKLMNPQRSTUVWXYZ23456789')
while True:
code = "".join([random.choice(charset) for i in range(5)])
if not Order.objects.filter(event=self.event, code=code).exists():
self.code = code
return
class QuestionAnswer(Versionable): class QuestionAnswer(Versionable):
""" """

View File

@@ -98,15 +98,15 @@ class BasePaymentProvider:
ctx = Context({'request': request, 'form': form}) ctx = Context({'request': request, 'form': form})
return template.render(ctx) return template.render(ctx)
def checkout_prepare(self, request, total): def checkout_prepare(self, request, total) -> "bool|HttpResponse":
""" """
Will be called if the user selects this provider as his payment method. Will be called if the user selects this provider as his payment method.
If the payment provider provides a form to the user to enter payment data, If the payment provider provides a form to the user to enter payment data,
this method should at least store the user's input into his session. this method should at least store the user's input into his session.
It should return True or False, depending of the validity of the user's input, It should return True or False, depending of the validity of the user's input,
if the frontend should continue with default behaviour, or a custom HTTP response if the frontend should continue with default behaviour, or a redirect URL,
(for example, a redirect), if you need special behaviour. if you need special behaviour.
On errors, it should use Django's message framework to display an error message On errors, it should use Django's message framework to display an error message
to the user (or the normal form validation error messages). to the user (or the normal form validation error messages).
@@ -121,10 +121,25 @@ class BasePaymentProvider:
else: else:
return False return False
def checkout_is_valid_session(self, request): def checkout_is_valid_session(self, request) -> bool:
""" """
This is called at the time the user tries to place the order. It should return This is called at the time the user tries to place the order. It should return
True, if the user's session is valid and all data your payment provider requires True, if the user's session is valid and all data your payment provider requires
in future steps is present. in future steps is present.
""" """
raise NotImplementedError() raise NotImplementedError()
def checkout_perform(self, request, order) -> str:
"""
Will be called if the user submitted his order successfully to initiate the
payment process.
It should return a custom redirct URL, if you need special behaviour, or None to
continue with default behaviour.
On errors, it should use Django's message framework to display an error message
to the user (or the normal form validation error messages).
:param order: The order object
"""
return None

View File

@@ -10,6 +10,7 @@ DEFAULTS = {
'max_items_per_order': '10', 'max_items_per_order': '10',
'attendee_names_asked': 'True', 'attendee_names_asked': 'True',
'attendee_names_required': 'False', 'attendee_names_required': 'False',
'reservation_time': '30',
} }

View File

@@ -18,6 +18,8 @@ urlpatterns = patterns(
name='event.checkout.payment'), name='event.checkout.payment'),
url(r'^checkout/confirm$', pretix.presale.views.checkout.OrderConfirm.as_view(), url(r'^checkout/confirm$', pretix.presale.views.checkout.OrderConfirm.as_view(),
name='event.checkout.confirm'), 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'), url(r'^login$', pretix.presale.views.event.EventLogin.as_view(), name='event.checkout.login'),
) )
)), )),

View File

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

View File

@@ -1,13 +1,15 @@
from datetime import timedelta
from django.contrib import messages from django.contrib import messages
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.db import transaction
from django.db.models import Q, Sum from django.db.models import Q, Sum
from django import forms from django import forms
from django.shortcuts import redirect from django.shortcuts import redirect
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now
from django.views.generic import View, TemplateView from django.views.generic import View, TemplateView
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django.http import HttpResponse from pretix.base.models import CartPosition, Question, QuestionAnswer, Quota, Order, OrderPosition
from pretix.base.models import CartPosition, Question, QuestionAnswer
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.presale.views import EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin from pretix.presale.views import EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin
@@ -102,6 +104,13 @@ class CheckoutView(TemplateView):
'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): class CheckoutStart(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_questions.html" template_name = "pretixpresale/event/checkout_questions.html"
@@ -207,8 +216,8 @@ class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, CheckoutView):
request.session['payment'] = p['provider'].identifier request.session['payment'] = p['provider'].identifier
total = self._total_order_value + p['provider'].calculate_fee(self._total_order_value) total = self._total_order_value + p['provider'].calculate_fee(self._total_order_value)
resp = p['provider'].checkout_prepare(request, total) resp = p['provider'].checkout_prepare(request, total)
if isinstance(resp, HttpResponse): if isinstance(resp, str):
return resp return redirect(str)
elif resp is True: elif resp is True:
return redirect(self.get_confirm_url()) return redirect(self.get_confirm_url())
else: else:
@@ -224,6 +233,22 @@ class PaymentDetails(EventViewMixin, EventLoginRequiredMixin, CheckoutView):
class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView): class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, CheckoutView):
template_name = "pretixpresale/event/checkout_confirm.html" 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): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['cart'] = self.get_cart() ctx['cart'] = self.get_cart()
@@ -263,9 +288,100 @@ class OrderConfirm(EventViewMixin, CartDisplayMixin, EventLoginRequiredMixin, Ch
return check return check
return super().get(request, *args, **kwargs) 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): def post(self, request, *args, **kwargs):
self.request = request self.request = request
check = self.check_process(request) check = self.check_process(request)
if check: if check:
return check return
return super().post(request, *args, **kwargs)
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())