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 %}
+
+ {% 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