diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 511d2dec95..d522ed66ea 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -531,7 +531,6 @@ class Quota(LoggedModel): return OrderPosition.objects.filter( self._position_lookup, order__status=Order.STATUS_PENDING, - order__expires__gte=now() ).distinct().count() def count_paid_orders(self): diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index efabe60e87..2d9aa1bd18 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -4,7 +4,6 @@ import string from datetime import datetime from decimal import Decimal -from django import forms from django.db import models from django.utils.timezone import now from django.utils.translation import ugettext_lazy as _ @@ -204,6 +203,13 @@ class Order(LoggedModel): return True return False # nothing there to modify + @property + def is_expired_by_time(self): + return ( + self.status == Order.STATUS_PENDING and self.expires < now() + and not self.event.settings.get('payment_term_expire_automatically') + ) + def _can_be_paid(self) -> Union[bool, str]: error_messages = { 'late': _("The payment is too late to be accepted."), @@ -212,7 +218,7 @@ class Order(LoggedModel): 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: + if self.status == self.STATUS_PENDING: return True if not self.event.settings.get('payment_term_accept_late'): return error_messages['late'] diff --git a/src/pretix/base/services/orders.py b/src/pretix/base/services/orders.py index 151752b730..67a0c39477 100644 --- a/src/pretix/base/services/orders.py +++ b/src/pretix/base/services/orders.py @@ -2,6 +2,7 @@ from datetime import datetime, timedelta from django.conf import settings from django.db import transaction +from django.dispatch import receiver from django.utils.timezone import now from django.utils.translation import ugettext as _ from typing import List @@ -19,7 +20,7 @@ from pretix.base.services.invoices import ( ) from pretix.base.services.mail import mail from pretix.base.signals import ( - order_paid, order_placed, register_payment_providers, + order_paid, order_placed, periodic_task, register_payment_providers, ) from pretix.multidomain.urlreverse import build_absolute_uri @@ -253,7 +254,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str], event = Event.objects.get(id=event) responses = register_payment_providers.send(event) pprov = None - for receiver, response in responses: + for rec, response in responses: provider = response(event) if provider.identifier == payment_provider: pprov = provider @@ -316,6 +317,20 @@ def perform_order(event: str, payment_provider: str, positions: List[str], raise OrderError(error_messages['busy']) +@receiver(signal=periodic_task) +def expire_orders(sender, **kwargs): + eventcache = {} + for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING).select_related('event'): + expire = eventcache.get(o.event.pk, None) + if expire is None: + expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool) + eventcache[o.event.pk] = expire + if expire: + o.status = Order.STATUS_EXPIRED + o.log_action('pretix.event.order.expired') + o.save() + + if settings.HAS_CELERY: from pretix.celery import app diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index c277723ea1..7bdeda5261 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -49,6 +49,10 @@ DEFAULTS = { 'default': None, 'type': datetime, }, + 'payment_term_expire_automatically': { + 'default': 'True', + 'type': bool + }, 'payment_term_accept_late': { 'default': 'True', 'type': bool diff --git a/src/pretix/control/forms/event.py b/src/pretix/control/forms/event.py index 3a7ad1add1..8805e26c5b 100644 --- a/src/pretix/control/forms/event.py +++ b/src/pretix/control/forms/event.py @@ -117,11 +117,17 @@ class EventSettingsForm(SettingsForm): "days configured above."), required=False ) + payment_term_expire_automatically = forms.BooleanField( + label=_('Automatically expire unpaid orders'), + help_text=_("If checked, all unpaid orders will automatically go from 'pending' to 'expired' " + "after the end of their payment deadline. This means that those tickets go back to " + "the pool and can be ordered by other people."), + 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' " + help_text=_("Accept payments for orders even when they are in 'expired' state as long as enough " + "capacity is available. No payments will ever be accepted after the 'Last date of payments' " "configured above."), required=False ) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index b22ccbd104..f6b441b99d 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -11,6 +11,7 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs): 'pretix.event.order.unpaid': _('The order has been marked as unpaid.'), 'pretix.event.order.resend': _('The link to the order detail page has been resent to the user.'), 'pretix.event.order.expirychanged': _('The order\'s expiry date has been changed.'), + 'pretix.event.order.expired': _('The order has been marked as expired.'), 'pretix.event.order.paid': _('The order has been marked as paid.'), 'pretix.event.order.refunded': _('The order has been refunded.'), 'pretix.event.order.cancelled': _('The order has been cancelled.'), diff --git a/src/pretix/control/templates/pretixcontrol/event/settings.html b/src/pretix/control/templates/pretixcontrol/event/settings.html index 5f4e177c93..5175727738 100644 --- a/src/pretix/control/templates/pretixcontrol/event/settings.html +++ b/src/pretix/control/templates/pretixcontrol/event/settings.html @@ -33,6 +33,7 @@ {% bootstrap_field sform.show_items_outside_presale_period layout="horizontal" %} {% bootstrap_field sform.payment_term_days layout="horizontal" %} {% bootstrap_field sform.payment_term_last layout="horizontal" %} + {% bootstrap_field sform.payment_term_expire_automatically 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/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index c68e9f3b96..fd38adadcb 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -47,6 +47,17 @@ {% endif %} {% endif %} + {% if order.is_expired_by_time %} +
+ {% csrf_token %} +
+ + {% trans "The payment for this order is overdue, but you have configured not to expire orders automatically. To free quota capacity, you can mark it as expired manually." %} +
+
+
+ {% endif %}
diff --git a/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html b/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html index a312973cb3..188793d5b4 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html +++ b/src/pretix/control/templates/pretixcontrol/orders/fragment_order_status.html @@ -5,7 +5,7 @@ {% elif order.status == "p" %} {% trans "Paid" %} {% elif order.status == "e" %} {# expired #} - {% trans "Pending (expired)" %} + {% trans "Expired" %} {% elif order.status == "c" %} {% trans "Cancelled" %} {% elif order.status == "r" %} diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 266f81c049..1bf61ed373 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -167,23 +167,28 @@ class OrderTransition(OrderView): def post(self, *args, **kwargs): to = self.request.POST.get('status', '') - if self.order.status == 'n' and to == 'p': + if self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and to == 'p': try: mark_order_paid(self.order, manual=True, user=self.request.user) 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 == Order.STATUS_PENDING and to == 'c': cancel_order(self.order, user=self.request.user) messages.success(self.request, _('The order has been cancelled.')) - elif self.order.status == 'p' and to == 'n': + elif self.order.status == Order.STATUS_PAID and to == 'n': self.order.status = Order.STATUS_PENDING self.order.payment_manual = True self.order.save() self.order.log_action('pretix.event.order.unpaid', user=self.request.user) messages.success(self.request, _('The order has been marked as not paid.')) - elif self.order.status == 'p' and to == 'r': + elif self.order.status == Order.STATUS_PENDING and to == 'e': + self.order.status = Order.STATUS_EXPIRED + self.order.save() + self.order.log_action('pretix.event.order.expired', user=self.request.user) + messages.success(self.request, _('The order has been marked as expired.')) + elif self.order.status == Order.STATUS_PAID and to == 'r': ret = self.payment_provider.order_control_refund_perform(self.request, self.order) if ret: return redirect(ret) @@ -191,11 +196,11 @@ class OrderTransition(OrderView): def get(self, *args, **kwargs): to = self.request.GET.get('status', '') - if self.order.status == 'n' and to == 'c': + if self.order.status == Order.STATUS_PENDING and to == 'c': return render(self.request, 'pretixcontrol/order/cancel.html', { 'order': self.order, }) - elif self.order.status == 'p' and to == 'r': + elif self.order.status == Order.STATUS_PAID and to == 'r': return render(self.request, 'pretixcontrol/order/refund.html', { 'order': self.order, 'payment': self.payment_provider.order_control_refund_render(self.order), diff --git a/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html b/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html index 02c53427dd..c6c0f9061d 100644 --- a/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html +++ b/src/pretix/presale/templates/pretixpresale/event/fragment_order_status.html @@ -5,7 +5,7 @@ {% elif order.status == "p" %} {% trans "Paid" %} {% elif order.status == "e" %} - {% trans "Payment pending" %} + {% trans "Expired" %} {% elif order.status == "c" %} {% trans "Cancelled" %} {% elif order.status == "r" %} diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index 70d28cfad7..ad42f2238d 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -103,6 +103,10 @@ class QuotaTestCase(BaseQuotaTestCase): order.expires = now() - timedelta(days=3) order.save() + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_ORDERED, 0)) + + order.status = Order.STATUS_EXPIRED + order.save() self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 1)) def test_ordered_multi_quota(self): @@ -234,6 +238,7 @@ class OrderTestCase(BaseQuotaTestCase): self.assertEqual(self.order.status, Order.STATUS_PAID) def test_paid_expired_available(self): + self.order.status = Order.STATUS_EXPIRED self.order.expires = now() - timedelta(days=2) self.order.save() mark_order_paid(self.order) @@ -241,6 +246,7 @@ class OrderTestCase(BaseQuotaTestCase): self.assertEqual(self.order.status, Order.STATUS_PAID) def test_paid_expired_partial(self): + self.order.status = Order.STATUS_EXPIRED self.order.expires = now() - timedelta(days=2) self.order.save() self.quota.size = 1 @@ -255,6 +261,7 @@ class OrderTestCase(BaseQuotaTestCase): def test_paid_expired_unavailable(self): self.order.expires = now() - timedelta(days=2) + self.order.status = Order.STATUS_EXPIRED self.order.save() self.quota.size = 0 self.quota.save() @@ -266,6 +273,13 @@ class OrderTestCase(BaseQuotaTestCase): self.order = Order.objects.get(id=self.order.id) self.assertIn(self.order.status, (Order.STATUS_PENDING, Order.STATUS_EXPIRED)) + def test_paid_after_deadline_but_not_expired(self): + self.order.expires = now() - timedelta(days=2) + self.order.save() + mark_order_paid(self.order) + self.order = Order.objects.get(identity=self.order.identity) + self.assertEqual(self.order.status, Order.STATUS_PAID) + def test_paid_expired_unavailable_force(self): self.order.expires = now() - timedelta(days=2) self.order.save() diff --git a/src/tests/base/test_orders.py b/src/tests/base/test_orders.py index 35465d6cf0..dde1962e6a 100644 --- a/src/tests/base/test_orders.py +++ b/src/tests/base/test_orders.py @@ -3,9 +3,9 @@ from datetime import timedelta import pytest from django.utils.timezone import now -from pretix.base.models import Event, Organizer +from pretix.base.models import Event, Order, Organizer from pretix.base.payment import FreeOrderProvider -from pretix.base.services.orders import _create_order +from pretix.base.services.orders import _create_order, expire_orders @pytest.fixture @@ -42,3 +42,46 @@ def test_expiry_last(event): dt=today, payment_provider=FreeOrderProvider(event), locale='de') assert (order.expires - today).days == 5 + + +@pytest.mark.django_db +def test_expiring(event): + o1 = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=0, payment_provider='banktransfer' + ) + o2 = Order.objects.create( + code='FO2', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() - timedelta(days=10), + total=0, payment_provider='banktransfer' + ) + expire_orders(None) + o1 = Order.objects.get(id=o1.id) + assert o1.status == Order.STATUS_PENDING + o2 = Order.objects.get(id=o2.id) + assert o2.status == Order.STATUS_EXPIRED + + +@pytest.mark.django_db +def test_expiring_auto_disabled(event): + event.settings.set('payment_term_expire_automatically', False) + o1 = Order.objects.create( + code='FOO', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() + timedelta(days=10), + total=0, payment_provider='banktransfer' + ) + o2 = Order.objects.create( + code='FO2', event=event, email='dummy@dummy.test', + status=Order.STATUS_PENDING, + datetime=now(), expires=now() - timedelta(days=10), + total=0, payment_provider='banktransfer' + ) + expire_orders(None) + o1 = Order.objects.get(id=o1.id) + assert o1.status == Order.STATUS_PENDING + o2 = Order.objects.get(id=o2.id) + assert o2.status == Order.STATUS_PENDING diff --git a/src/tests/control/test_orders.py b/src/tests/control/test_orders.py index e7f1714b82..20eee5a692 100644 --- a/src/tests/control/test_orders.py +++ b/src/tests/control/test_orders.py @@ -87,7 +87,7 @@ def test_order_transition_to_paid_in_time_success(client, env): @pytest.mark.django_db def test_order_transition_to_paid_expired_quota_left(client, env): o = Order.objects.get(id=env[2].id) - o.expires = now() - timedelta(days=2) + o.status = Order.STATUS_EXPIRED o.save() q = Quota.objects.create(event=env[0], size=10) q.items.add(env[3]) @@ -103,7 +103,7 @@ def test_order_transition_to_paid_expired_quota_left(client, env): @pytest.mark.django_db def test_order_transition_to_paid_expired_quota_full(client, env): o = Order.objects.get(id=env[2].id) - o.expires = now() - timedelta(days=2) + o.status = Order.STATUS_EXPIRED o.save() q = Quota.objects.create(event=env[0], size=0) q.items.add(env[3]) @@ -112,7 +112,7 @@ def test_order_transition_to_paid_expired_quota_full(client, env): 'status': 'p' }) o = Order.objects.get(id=env[2].id) - assert o.status == Order.STATUS_PENDING + assert o.status == Order.STATUS_EXPIRED @pytest.mark.django_db