Properly implement quota handling when receiving payments (closes #11)

This commit is contained in:
Raphael Michel
2015-04-14 16:20:05 +02:00
parent 2f7ab1957a
commit df524f31d5
10 changed files with 194 additions and 19 deletions

View File

@@ -1359,6 +1359,9 @@ class Quota(Versionable):
class LockTimeoutException(Exception): class LockTimeoutException(Exception):
pass pass
class QuotaExceededException(Exception):
pass
def lock(self): def lock(self):
""" """
Issue a lock on this quota so nobody can take tickets from this quota until Issue a lock on this quota so nobody can take tickets from this quota until
@@ -1565,7 +1568,63 @@ class Order(Versionable):
order.save() order.save()
return order return order
def mark_paid(self, provider=None, info=None, date=None, manual=None): def _can_be_paid(self):
error_messages = {
'unavailable': _('Some of the ordered products were no longer available.'),
'busy': _('We were not able to process the request completely as the '
'server was too busy. Please try again.'),
'late': _("The payment is too late to be accepted."),
}
if self.event.settings.get('payment_term_last') \
and now() > self.event.settings.get('payment_term_last'):
return error_messages['late']
if now() < self.expires:
return True
if not self.event.settings.get('payment_term_accept_late'):
return error_messages['late']
positions = list(self.positions.all().select_related(
'item', 'variation'
).prefetch_related(
'variation__values', 'variation__values__prop',
'item__questions', 'answers'
))
quotas_locked = set()
try:
for i, op in enumerate(positions):
quotas = list(op.item.quotas.all()) if op.variation is None else list(op.variation.quotas.all())
if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'])
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)
quota.cached_availability = quota.availability()[1]
else:
# Use cached version
quota = [q for q in quotas_locked if q.pk == quota.pk][0]
quota.cached_availability -= 1
if quota.cached_availability < 0:
# This quota is sold out/currently unavailable, so do not sell this at all
raise Quota.QuotaExceededException(error_messages['unavailable'])
except Quota.QuotaExceededException as e:
return str(e)
except Quota.LockTimeoutException:
# Is raised when there are too many threads asking for quota locks and we were
# unaible to get one
return error_messages['busy']
finally:
# Release the locks. This is important ;)
for quota in quotas_locked:
quota.release()
return True
def mark_paid(self, provider=None, info=None, date=None, manual=None, force=False):
""" """
Mark this order as paid. This clones the order object, sets the payment provider, Mark this order as paid. This clones the order object, sets the payment provider,
info and date and returns the cloned order object. info and date and returns the cloned order object.
@@ -1577,7 +1636,14 @@ class Order(Versionable):
:param date: The date the payment was received (if you pass ``None``, the current :param date: The date the payment was received (if you pass ``None``, the current
time will be used). time will be used).
:type date: datetime :type date: datetime
:param force: Whether this payment should be marked as paid even if no remaining
quota is available (default: ``False``).
:type force: boolean
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
""" """
can_be_paid = self._can_be_paid()
if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid)
order = self.clone() order = self.clone()
order.payment_provider = provider or order.payment_provider order.payment_provider = provider or order.payment_provider
order.payment_info = info or order.payment_info order.payment_info = info or order.payment_info

View File

@@ -239,8 +239,11 @@ class BasePaymentProvider:
If the payment is completed, you should call ``order.mark_paid(provider, info)`` If the payment is completed, you should call ``order.mark_paid(provider, info)``
with ``provider`` being your :py:attr:`identifier` and ``info`` being any string with ``provider`` being your :py:attr:`identifier` and ``info`` being any string
you might want to store for later usage. Please note, that if you want to store you might want to store for later usage. Please note, that if you want to store
something inside ``order.payment_info``, please do a ``order = order.clone()`` before something inside ``order.payment_info``, please do it after the ``mark_paid`` call,
modifying or saving the order object. as this call does a object clone for you. Please also note that ``mark_paid`` might
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this
order is over and some of the items are sold out. You should use the exception message
to display a meaningful error to the user.
The default implementation just returns ``None`` and therefore leaves the The default implementation just returns ``None`` and therefore leaves the
order unpaid. The user will be redirected to the order's detail page by default. order unpaid. The user will be redirected to the order's detail page by default.

View File

@@ -37,6 +37,10 @@ DEFAULTS = {
'default': None, 'default': None,
'type': datetime, 'type': datetime,
}, },
'payment_term_accept_late': {
'default': 'True',
'type': bool
},
'timezone': { 'timezone': {
'default': settings.TIME_ZONE, 'default': settings.TIME_ZONE,
'type': str 'type': str

View File

@@ -31,6 +31,7 @@
{% bootstrap_field form.presale_end layout="horizontal" %} {% bootstrap_field form.presale_end layout="horizontal" %}
{% bootstrap_field sform.payment_term_days layout="horizontal" %} {% bootstrap_field sform.payment_term_days layout="horizontal" %}
{% bootstrap_field sform.payment_term_last layout="horizontal" %} {% bootstrap_field sform.payment_term_last layout="horizontal" %}
{% bootstrap_field sform.payment_term_accept_late layout="horizontal" %}
{% bootstrap_field sform.last_order_modification_date layout="horizontal" %} {% bootstrap_field sform.last_order_modification_date layout="horizontal" %}
</fieldset> </fieldset>
<fieldset> <fieldset>

View File

@@ -61,6 +61,14 @@ class EventSettingsForm(SettingsForm):
"days configured above."), "days configured above."),
required=False required=False
) )
payment_term_accept_late = forms.BooleanField(
label='Accept late payments',
help_text=_("Accept payments that come after the end of the order's payment term. "
"Payments will only be accepted if the regarding quotas have remaining "
"capacity. No payments will be accepted after the 'Last date of payments' "
"configured above."),
required=False
)
last_order_modification_date = forms.DateTimeField( last_order_modification_date = forms.DateTimeField(
label='Last date of modifications', label='Last date of modifications',
help_text=_("The last date users can modify details of their orders, such as attendee names or " help_text=_("The last date users can modify details of their orders, such as attendee names or "

View File

@@ -8,7 +8,7 @@ from django.shortcuts import redirect, render
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.views.generic import ListView, DetailView from django.views.generic import ListView, DetailView
from pretix.base.models import Order from pretix.base.models import Order, Quota
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
@@ -105,8 +105,12 @@ class OrderTransition(EventPermissionRequiredMixin, OrderView):
def post(self, *args, **kwargs): def post(self, *args, **kwargs):
to = self.request.POST.get('status', '') to = self.request.POST.get('status', '')
if self.order.status == 'n' and to == 'p': if self.order.status == 'n' and to == 'p':
self.order.mark_paid(manual=True) try:
messages.success(self.request, _('The order has been marked as paid.')) self.order.mark_paid(manual=True)
except Quota.QuotaExceededException as e:
messages.error(self.request, str(e))
else:
messages.success(self.request, _('The order has been marked as paid.'))
elif self.order.status == 'n' and to == 'c': elif self.order.status == 'n' and to == 'c':
order = self.order.clone() order = self.order.clone()
order.status = Order.STATUS_CANCELLED order.status = Order.STATUS_CANCELLED

View File

@@ -8,7 +8,7 @@ from django.core.urlresolvers import reverse
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.utils.timezone import now from django.utils.timezone import now
from django.views.generic import TemplateView from django.views.generic import TemplateView
from pretix.base.models import Order from pretix.base.models import Order, Quota
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.plugins.banktransfer import csvimport, mt940import from pretix.plugins.banktransfer import csvimport, mt940import
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
@@ -33,15 +33,25 @@ class ImportView(EventPermissionRequiredMixin, TemplateView):
if 'confirm' in self.request.POST: if 'confirm' in self.request.POST:
orders = Order.objects.filter(event=self.request.event, orders = Order.objects.filter(event=self.request.event,
code__in=self.request.POST.getlist('mark_paid')) code__in=self.request.POST.getlist('mark_paid'))
some_failed = False
for order in orders: for order in orders:
order.mark_paid(provider='banktransfer', info=json.dumps({ try:
'reference': self.request.POST.get('reference_%s' % order.code), order.mark_paid(provider='banktransfer', info=json.dumps({
'date': self.request.POST.get('date_%s' % order.code), 'reference': self.request.POST.get('reference_%s' % order.code),
'payer': self.request.POST.get('payer_%s' % order.code), 'date': self.request.POST.get('date_%s' % order.code),
'import': now().isoformat(), 'payer': self.request.POST.get('payer_%s' % order.code),
})) 'import': now().isoformat(),
}))
except Quota.QuotaExceededException:
some_failed = True
messages.success(self.request, _('The selected orders have been marked as paid.')) if some_failed:
messages.success(self.request, _('The selected orders have been marked as paid.'))
else:
messages.warning(self.request, _('Not all of the selected orders could be marked as '
'paid as some of them have expired and the selected '
'items are sold out.'))
# TODO: Display a list of them!
return self.redirect_back() return self.redirect_back()
messages.error(self.request, _('We were unable to detect the file type of this import. Please ' messages.error(self.request, _('We were unable to detect the file type of this import. Please '

View File

@@ -9,6 +9,7 @@ from django.utils.translation import ugettext as __
from django import forms from django import forms
import paypalrestsdk import paypalrestsdk
from pretix.base.models import Quota
from pretix.base.payment import BasePaymentProvider from pretix.base.payment import BasePaymentProvider
@@ -175,8 +176,11 @@ class Paypal(BasePaymentProvider):
logger.error('Invalid state: %s' % str(payment)) logger.error('Invalid state: %s' % str(payment))
return return
order.mark_paid('paypal', json.dumps(payment.to_dict())) try:
messages.success(request, _('We successfully received your payment. Thank you!')) order.mark_paid('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 return None
def order_pending_render(self, request, order) -> str: def order_pending_render(self, request, order) -> str:

View File

@@ -5,6 +5,7 @@ from django.contrib import messages
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django import forms from django import forms
from pretix.base.models import Quota
import stripe import stripe
from pretix.base.payment import BasePaymentProvider from pretix.base.payment import BasePaymentProvider
@@ -67,7 +68,11 @@ class Stripe(BasePaymentProvider):
) )
logging.info(charge) logging.info(charge)
if charge.status == 'succeeded' and charge.paid: if charge.status == 'succeeded' and charge.paid:
order.mark_paid('stripe', str(charge)) try:
order.mark_paid('paypal', str(charge))
messages.success(request, _('We successfully received your payment. Thank you!'))
except Quota.QuotaExceededException as e:
messages.error(request, str(e))
messages.success(request, _('We successfully received your payment. Thank you!')) messages.success(request, _('We successfully received your payment. Thank you!'))
else: else:
messages.warning(request, _('Stripe reported an error: %s' % charge.failure_message)) messages.warning(request, _('Stripe reported an error: %s' % charge.failure_message))

View File

@@ -176,7 +176,8 @@ class UserTestCase(TestCase):
self.assertEqual(u.identifier, "test@example.com") self.assertEqual(u.identifier, "test@example.com")
class QuotaTestCase(TestCase): class BaseQuotaTestCase(TestCase):
@classmethod @classmethod
def setUpTestData(cls): def setUpTestData(cls):
o = Organizer.objects.create(name='Dummy', slug='dummy') o = Organizer.objects.create(name='Dummy', slug='dummy')
@@ -187,7 +188,7 @@ class QuotaTestCase(TestCase):
def setUp(self): def setUp(self):
self.quota = Quota.objects.create(name="Test", size=2, event=self.event) self.quota = Quota.objects.create(name="Test", size=2, event=self.event)
self.item1 = Item.objects.create(event=self.event, name="Ticket") self.item1 = Item.objects.create(event=self.event, name="Ticket", default_price=23)
self.item2 = Item.objects.create(event=self.event, name="T-Shirt") self.item2 = Item.objects.create(event=self.event, name="T-Shirt")
p = Property.objects.create(event=self.event, name='Size') p = Property.objects.create(event=self.event, name='Size')
pv1 = PropertyValue.objects.create(prop=p, value='S') pv1 = PropertyValue.objects.create(prop=p, value='S')
@@ -197,6 +198,9 @@ class QuotaTestCase(TestCase):
self.var1.values.add(pv1) self.var1.values.add(pv1)
self.item2.properties.add(p) self.item2.properties.add(p)
class QuotaTestCase(BaseQuotaTestCase):
def test_available(self): def test_available(self):
self.quota.items.add(self.item1) self.quota.items.add(self.item1)
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 2)) self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 2))
@@ -289,3 +293,69 @@ class QuotaTestCase(TestCase):
quota2.size = 0 quota2.size = 0
quota2.save() quota2.save()
self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_GONE, 0)) self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_GONE, 0))
class OrderTestCase(BaseQuotaTestCase):
def setUp(self):
super().setUp()
self.user = User.objects.create_local_user(self.event, 'dummy', 'dummy')
self.order = Order.objects.create(
status=Order.STATUS_PENDING, event=self.event,
user=self.user, datetime=now() - timedelta(days=5),
expires=now() + timedelta(days=5), total=46
)
self.quota.items.add(self.item1)
OrderPosition.objects.create(order=self.order, item=self.item1,
variation=None, price=23)
OrderPosition.objects.create(order=self.order, item=self.item1,
variation=None, price=23)
def test_paid_in_time(self):
self.quota.size = 0
self.quota.save()
self.order.mark_paid()
self.order = Order.objects.current.get(identity=self.order.identity)
self.assertEqual(self.order.status, Order.STATUS_PAID)
def test_paid_expired_available(self):
self.order.expires = now() - timedelta(days=2)
self.order.save()
self.order.mark_paid()
self.order = Order.objects.current.get(identity=self.order.identity)
self.assertEqual(self.order.status, Order.STATUS_PAID)
def test_paid_expired_partial(self):
self.order.expires = now() - timedelta(days=2)
self.order.save()
self.quota.size = 1
self.quota.save()
try:
self.order.mark_paid()
self.assertFalse(True, 'This should have raised an exception.')
except Quota.QuotaExceededException:
pass
self.order = Order.objects.current.get(identity=self.order.identity)
self.assertIn(self.order.status, (Order.STATUS_PENDING, Order.STATUS_EXPIRED))
def test_paid_expired_unavailable(self):
self.order.expires = now() - timedelta(days=2)
self.order.save()
self.quota.size = 0
self.quota.save()
try:
self.order.mark_paid()
self.assertFalse(True, 'This should have raised an exception.')
except Quota.QuotaExceededException:
pass
self.order = Order.objects.current.get(identity=self.order.identity)
self.assertIn(self.order.status, (Order.STATUS_PENDING, Order.STATUS_EXPIRED))
def test_paid_expired_unavailable_force(self):
self.order.expires = now() - timedelta(days=2)
self.order.save()
self.quota.size = 0
self.quota.save()
self.order.mark_paid(force=True)
self.order = Order.objects.current.get(identity=self.order.identity)
self.assertEqual(self.order.status, Order.STATUS_PAID)