diff --git a/src/pretix/base/models.py b/src/pretix/base/models.py index 9a6b0510bb..eeb6491076 100644 --- a/src/pretix/base/models.py +++ b/src/pretix/base/models.py @@ -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 diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index 1234fd9504..e6dadc0860 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -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. diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index ab8c9d667b..92e5ed967a 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -37,6 +37,10 @@ DEFAULTS = { 'default': None, 'type': datetime, }, + 'payment_term_accept_late': { + 'default': 'True', + 'type': bool + }, 'timezone': { 'default': settings.TIME_ZONE, 'type': str diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 92de3fcf71..30f07b1c53 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -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" %}
diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index 7a6a385938..e672e21d8f 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -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 " diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 2eb69d7d68..7dd0a550da 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -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 diff --git a/src/pretix/plugins/banktransfer/views.py b/src/pretix/plugins/banktransfer/views.py index a86a7b313a..b9bb2c475d 100644 --- a/src/pretix/plugins/banktransfer/views.py +++ b/src/pretix/plugins/banktransfer/views.py @@ -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 ' diff --git a/src/pretix/plugins/paypal/payment.py b/src/pretix/plugins/paypal/payment.py index 3a639aeaa6..686a960266 100644 --- a/src/pretix/plugins/paypal/payment.py +++ b/src/pretix/plugins/paypal/payment.py @@ -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: diff --git a/src/pretix/plugins/stripe/payment.py b/src/pretix/plugins/stripe/payment.py index 2f3a62b2fa..97739d42b5 100644 --- a/src/pretix/plugins/stripe/payment.py +++ b/src/pretix/plugins/stripe/payment.py @@ -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)) diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index e1445ac130..d348b7eaa0 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -176,7 +176,8 @@ class UserTestCase(TestCase): self.assertEqual(u.identifier, "test@example.com") -class QuotaTestCase(TestCase): +class BaseQuotaTestCase(TestCase): + @classmethod def setUpTestData(cls): o = Organizer.objects.create(name='Dummy', slug='dummy') @@ -187,7 +188,7 @@ class QuotaTestCase(TestCase): def setUp(self): 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") p = Property.objects.create(event=self.event, name='Size') pv1 = PropertyValue.objects.create(prop=p, value='S') @@ -197,6 +198,9 @@ class QuotaTestCase(TestCase): self.var1.values.add(pv1) self.item2.properties.add(p) + +class QuotaTestCase(BaseQuotaTestCase): + def test_available(self): self.quota.items.add(self.item1) self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 2)) @@ -289,3 +293,69 @@ class QuotaTestCase(TestCase): quota2.size = 0 quota2.save() 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)