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):
pass
class QuotaExceededException(Exception):
pass
def lock(self):
"""
Issue a lock on this quota so nobody can take tickets from this quota until
@@ -1565,7 +1568,63 @@ class Order(Versionable):
order.save()
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,
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
time will be used).
: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.payment_provider = provider or order.payment_provider
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)``
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
something inside ``order.payment_info``, please do a ``order = order.clone()`` before
modifying or saving the order object.
something inside ``order.payment_info``, please do it after the ``mark_paid`` call,
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
order unpaid. The user will be redirected to the order's detail page by default.

View File

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

View File

@@ -31,6 +31,7 @@
{% bootstrap_field form.presale_end layout="horizontal" %}
{% bootstrap_field sform.payment_term_days 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" %}
</fieldset>
<fieldset>

View File

@@ -61,6 +61,14 @@ class EventSettingsForm(SettingsForm):
"days configured above."),
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(
label='Last date of modifications',
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.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.control.permissions import EventPermissionRequiredMixin
@@ -105,8 +105,12 @@ class OrderTransition(EventPermissionRequiredMixin, OrderView):
def post(self, *args, **kwargs):
to = self.request.POST.get('status', '')
if self.order.status == 'n' and to == 'p':
self.order.mark_paid(manual=True)
messages.success(self.request, _('The order has been marked as paid.'))
try:
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':
order = self.order.clone()
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.utils.timezone import now
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.plugins.banktransfer import csvimport, mt940import
from django.utils.translation import ugettext_lazy as _
@@ -33,15 +33,25 @@ class ImportView(EventPermissionRequiredMixin, TemplateView):
if 'confirm' in self.request.POST:
orders = Order.objects.filter(event=self.request.event,
code__in=self.request.POST.getlist('mark_paid'))
some_failed = False
for order in orders:
order.mark_paid(provider='banktransfer', info=json.dumps({
'reference': self.request.POST.get('reference_%s' % order.code),
'date': self.request.POST.get('date_%s' % order.code),
'payer': self.request.POST.get('payer_%s' % order.code),
'import': now().isoformat(),
}))
try:
order.mark_paid(provider='banktransfer', info=json.dumps({
'reference': self.request.POST.get('reference_%s' % order.code),
'date': self.request.POST.get('date_%s' % order.code),
'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()
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
import paypalrestsdk
from pretix.base.models import Quota
from pretix.base.payment import BasePaymentProvider
@@ -175,8 +176,11 @@ class Paypal(BasePaymentProvider):
logger.error('Invalid state: %s' % str(payment))
return
order.mark_paid('paypal', json.dumps(payment.to_dict()))
messages.success(request, _('We successfully received your payment. Thank you!'))
try:
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
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.utils.translation import ugettext_lazy as _
from django import forms
from pretix.base.models import Quota
import stripe
from pretix.base.payment import BasePaymentProvider
@@ -67,7 +68,11 @@ class Stripe(BasePaymentProvider):
)
logging.info(charge)
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!'))
else:
messages.warning(request, _('Stripe reported an error: %s' % charge.failure_message))