diff --git a/doc/api/resources/quotas.rst b/doc/api/resources/quotas.rst index 70b2d2cc74..0ef7be6e7f 100644 --- a/doc/api/resources/quotas.rst +++ b/doc/api/resources/quotas.rst @@ -283,6 +283,7 @@ Endpoints "total_size": 1000, "pending_orders": 25, "paid_orders": 423, + "exited_orders": 0, "cart_positions": 7, "blocking_vouchers": 126, "waiting_list": 0 diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 8b95058fa3..ff90522885 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -542,6 +542,7 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): data = { 'paid_orders': qa.count_paid_orders[quota], 'pending_orders': qa.count_pending_orders[quota], + 'exited_orders': qa.count_exited_orders[quota], 'blocking_vouchers': qa.count_vouchers[quota], 'cart_positions': qa.count_cart[quota], 'waiting_list': qa.count_pending_orders[quota], diff --git a/src/pretix/base/exporters/orderlist.py b/src/pretix/base/exporters/orderlist.py index bffcbee857..5a2c7fa94b 100644 --- a/src/pretix/base/exporters/orderlist.py +++ b/src/pretix/base/exporters/orderlist.py @@ -524,7 +524,7 @@ class QuotaListExporter(ListExporter): def iterate_list(self, form_data): headers = [ _('Quota name'), _('Total quota'), _('Paid orders'), _('Pending orders'), _('Blocking vouchers'), - _('Current user\'s carts'), _('Waiting list'), _('Current availability') + _('Current user\'s carts'), _('Waiting list'), _('Exited orders'), _('Current availability') ] yield headers @@ -543,6 +543,7 @@ class QuotaListExporter(ListExporter): qa.count_vouchers[quota], qa.count_cart[quota], qa.count_waitinglist[quota], + qa.count_exited_orders[quota], _('Infinite') if avail[1] is None else avail[1] ] yield row diff --git a/src/pretix/base/migrations/0155_quota_release_after_exit.py b/src/pretix/base/migrations/0155_quota_release_after_exit.py new file mode 100644 index 0000000000..2f5dabd117 --- /dev/null +++ b/src/pretix/base/migrations/0155_quota_release_after_exit.py @@ -0,0 +1,18 @@ +# Generated by Django 3.0.6 on 2020-06-26 14:20 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0154_auto_20200620_1633'), + ] + + operations = [ + migrations.AddField( + model_name='quota', + name='release_after_exit', + field=models.BooleanField(default=False), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index d0629ba47d..c3b66adb34 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1335,6 +1335,16 @@ class Quota(LoggedModel): ) closed = models.BooleanField(default=False) + release_after_exit = models.BooleanField( + verbose_name=_('Allow to sell more tickets once people have checked out'), + help_text=_('With this option, quota will be released as soon as people are scanned at an exit of your event. ' + 'This will only happen if they have been scanned both at an entry and at an exit and the exit ' + 'is the more recent scan. It does not matter which check-in list either of the scans was on, ' + 'but check-in lists are ignored if they are set to "Allow re-entering after an exit scan" to ' + 'prevent accidental overbooking.'), + default=False, + ) + objects = ScopedManager(organizer='event__organizer') class Meta: diff --git a/src/pretix/base/services/quotas.py b/src/pretix/base/services/quotas.py index 3751d0aaee..7617e91c39 100644 --- a/src/pretix/base/services/quotas.py +++ b/src/pretix/base/services/quotas.py @@ -3,14 +3,17 @@ from collections import Counter, defaultdict from datetime import timedelta from django.conf import settings -from django.db.models import Count, F, Func, Max, Q, Sum +from django.db import models +from django.db.models import ( + Case, Count, F, Func, Max, OuterRef, Q, Subquery, Sum, Value, When, +) from django.dispatch import receiver from django.utils.timezone import now from django_scopes import scopes_disabled from pretix.base.models import ( - CartPosition, Event, LogEntry, Order, OrderPosition, Quota, Voucher, - WaitingListEntry, + CartPosition, Checkin, Event, LogEntry, Order, OrderPosition, Quota, + Voucher, WaitingListEntry, ) from pretix.celery_app import app @@ -74,6 +77,7 @@ class QuotaAvailability: self.results = {} self.count_paid_orders = defaultdict(int) self.count_pending_orders = defaultdict(int) + self.count_exited_orders = defaultdict(int) self.count_vouchers = defaultdict(int) self.count_waitinglist = defaultdict(int) self.count_cart = defaultdict(int) @@ -214,25 +218,63 @@ class QuotaAvailability: Q(item_id__in={i['item_id'] for i in q_items if self._quota_objects[i['quota_id']] in quotas}) ) | Q( variation_id__in={i['itemvariation_id'] for i in q_vars if self._quota_objects[i['quota_id']] in quotas}) - ).order_by().values('order__status', 'item_id', 'subevent_id', 'variation_id').annotate(c=Count('*')) - for line in sorted(op_lookup, key=lambda li: li['order__status'], reverse=True): # p before n + ).order_by() + if any(q.release_after_exit for q in quotas): + op_lookup = op_lookup.annotate( + last_entry=Subquery( + Checkin.objects.filter( + position_id=OuterRef('pk'), + list__allow_entry_after_exit=False, + type=Checkin.TYPE_ENTRY, + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') + ), + last_exit=Subquery( + Checkin.objects.filter( + position_id=OuterRef('pk'), + list__allow_entry_after_exit=False, + type=Checkin.TYPE_EXIT, + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') + ), + ).annotate( + is_exited=Case( + When( + Q(last_entry__isnull=False) & Q(last_exit__isnull=False) & Q(last_exit__gt=F('last_entry')), + then=Value(1, output_field=models.IntegerField()), + ), + default=Value(0, output_field=models.IntegerField()), + output_field=models.IntegerField(), + ), + ) + else: + op_lookup = op_lookup.annotate( + is_exited=Value(0, output_field=models.IntegerField()) + ) + op_lookup = op_lookup.values('order__status', 'item_id', 'subevent_id', 'variation_id', 'is_exited').annotate(c=Count('*')) + for line in sorted(op_lookup, key=lambda li: (int(li['is_exited']), li['order__status']), reverse=True): # p before n, exited before non-exited if line['variation_id']: qs = self._var_to_quotas[line['variation_id']] else: qs = self._item_to_quotas[line['item_id']] for q in qs: if q.subevent_id == line['subevent_id']: - size_left[q] -= line['c'] - if line['order__status'] == Order.STATUS_PAID: - self.count_paid_orders[q] += line['c'] - q.cached_availability_paid_orders = line['c'] - elif line['order__status'] == Order.STATUS_PENDING: - self.count_pending_orders[q] += line['c'] - if size_left[q] <= 0 and q not in self.results: + if q.release_after_exit and line['is_exited']: + self.count_exited_orders[q] += line['c'] + else: + size_left[q] -= line['c'] if line['order__status'] == Order.STATUS_PAID: - self.results[q] = Quota.AVAILABILITY_GONE, 0 - else: - self.results[q] = Quota.AVAILABILITY_ORDERED, 0 + self.count_paid_orders[q] += line['c'] + q.cached_availability_paid_orders = self.count_paid_orders[q] + elif line['order__status'] == Order.STATUS_PENDING: + self.count_pending_orders[q] += line['c'] + if size_left[q] <= 0 and q not in self.results: + if line['order__status'] == Order.STATUS_PAID: + self.results[q] = Quota.AVAILABILITY_GONE, 0 + else: + self.results[q] = Quota.AVAILABILITY_ORDERED, 0 def _compute_vouchers(self, quotas, q_items, q_vars, size_left, now_dt): events = {q.event_id for q in quotas} diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index faac146eb0..56fd22f278 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -186,7 +186,8 @@ class QuotaForm(I18nModelForm): 'name', 'size', 'subevent', - 'close_when_sold_out' + 'close_when_sold_out', + 'release_after_exit', ] field_classes = { 'subevent': SafeModelChoiceField, diff --git a/src/pretix/control/templates/pretixcontrol/items/quota.html b/src/pretix/control/templates/pretixcontrol/items/quota.html index d04790c047..0af4b7395f 100644 --- a/src/pretix/control/templates/pretixcontrol/items/quota.html +++ b/src/pretix/control/templates/pretixcontrol/items/quota.html @@ -9,7 +9,7 @@ {% blocktrans with name=quota.name %}Quota: {{ name }}{% endblocktrans %} {% if 'can_change_items' in request.eventpermset %} + class="btn btn-default"> {% trans "Edit quota" %} @@ -34,7 +34,8 @@ {% else %}
- + {% trans "This quota is closed since it has been sold out before. Tickets are theoretically available, but will not be sold unless you manually re-open the quota." %}
@@ -65,9 +66,11 @@ {% if row.strong %}{% endif %}
- {% if row.strong %}{% endif %} - {% if not row.strong %}–{% endif %} {{ row.value }} - {% if row.strong %}{% endif %} + {% if row.strong %} + {{ row.value }} + {% else %} + {% if row.value >= 0 %}– {{ row.value }}{% else %}+ {{ row.value_abs }}{% endif %} + {% endif %}
{% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/items/quota_edit.html b/src/pretix/control/templates/pretixcontrol/items/quota_edit.html index 1a126cbabc..9221b673cd 100644 --- a/src/pretix/control/templates/pretixcontrol/items/quota_edit.html +++ b/src/pretix/control/templates/pretixcontrol/items/quota_edit.html @@ -40,6 +40,7 @@
{% trans "Advanced options" %} {% bootstrap_field form.close_when_sold_out layout="control" %} + {% bootstrap_field form.release_after_exit layout="control" %}
{% endfor %} @@ -449,6 +450,7 @@
{% bootstrap_field formset.empty_form.size layout="control" %} {% bootstrap_field formset.empty_form.itemvars layout="control" %} + {% bootstrap_field formset.empty_form.release_after_exit layout="control" %}
{% endescapescript %} diff --git a/src/pretix/control/templates/pretixcontrol/subevents/detail.html b/src/pretix/control/templates/pretixcontrol/subevents/detail.html index 9f0a6172f7..c9b4ec586d 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/detail.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/detail.html @@ -106,6 +106,7 @@ {% bootstrap_form_errors form %} {% bootstrap_field form.size layout="control" %} {% bootstrap_field form.itemvars layout="control" %} + {% bootstrap_field form.release_after_exit layout="control" %} {% endfor %} @@ -133,6 +134,7 @@
{% bootstrap_field formset.empty_form.size layout="control" %} {% bootstrap_field formset.empty_form.itemvars layout="control" %} + {% bootstrap_field formset.empty_form.release_after_exit layout="control" %}
{% endescapescript %} diff --git a/src/pretix/control/views/item.py b/src/pretix/control/views/item.py index 60cba92ca8..499e7562ea 100644 --- a/src/pretix/control/views/item.py +++ b/src/pretix/control/views/item.py @@ -781,6 +781,15 @@ class QuotaView(ChartContainingView, DetailView): 'value': qa.count_pending_orders[self.object], 'sum': True, }, + ] + if self.object.release_after_exit: + data.append({ + 'label': gettext('Exit scans'), + 'value': -1 * qa.count_exited_orders[self.object], + 'sum': True, + }) + + data += [ { 'label': gettext('Vouchers and waiting list reservations'), 'value': qa.count_vouchers[self.object], @@ -816,7 +825,10 @@ class QuotaView(ChartContainingView, DetailView): 'strong': True }) - ctx['quota_chart_data'] = json.dumps([r for r in data if r.get('sum')]) + for d in data: + if d.get('value', 0) < 0: + d['value_abs'] = abs(d['value']) + ctx['quota_chart_data'] = json.dumps([r for r in data if r.get('sum') and r['value'] >= 0]) ctx['quota_table_rows'] = list(data) ctx['quota_overbooked'] = sum_values - self.object.size if self.object.size is not None else 0 diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index f32b328493..1208694a78 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -216,6 +216,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): { 'size': q.size, 'name': q.name, + 'release_after_exit': q.release_after_exit, 'itemvars': [str(i.pk) for i in q.items.all()] + [ '{}-{}'.format(v.item_id, v.pk) for v in q.variations.all() ] diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 00dfedabee..b6eda7efc7 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -1765,6 +1765,7 @@ def test_quota_availability(token_client, organizer, event, quota, item): assert {'blocking_vouchers': 0, 'available_number': 200, 'pending_orders': 0, + 'exited_orders': 0, 'cart_positions': 0, 'available': True, 'total_size': 200, diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index ed014e17ed..24f9fb6308 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -12,7 +12,7 @@ from django.core.files.storage import default_storage from django.core.files.uploadedfile import SimpleUploadedFile from django.test import TestCase from django.utils.timezone import now -from django_scopes import scope +from django_scopes import scope, scopes_disabled from pretix.base.i18n import language from pretix.base.models import ( @@ -524,6 +524,64 @@ class QuotaTestCase(BaseQuotaTestCase): assert self.quota.availability() == (Quota.AVAILABILITY_ORDERED, 0) +class CheckinQuotaTestCase(BaseQuotaTestCase): + + @scopes_disabled() + def setUp(self): + super().setUp() + self.quota.size = 5 + self.quota.release_after_exit = True + self.quota.save() + self.quota.items.add(self.item1) + self.cl = self.event.checkin_lists.create(name="Test", allow_entry_after_exit=False) + order = Order.objects.create(event=self.event, status=Order.STATUS_PAID, + expires=now() + timedelta(days=3), + total=4) + self.op = OrderPosition.objects.create(order=order, item=self.item1, price=2) + + @classscope(attr='o') + def test_not_checked_in(self): + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 4)) + + @classscope(attr='o') + def test_checked_in(self): + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_ENTRY, datetime=now() - timedelta(minutes=5)) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 4)) + + @classscope(attr='o') + def test_checked_in_and_out(self): + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_ENTRY, datetime=now() - timedelta(minutes=5)) + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_EXIT, datetime=now() - timedelta(minutes=2)) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 5)) + + @classscope(attr='o') + def test_wrong_order(self): + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_ENTRY, datetime=now() - timedelta(minutes=2)) + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_EXIT, datetime=now() - timedelta(minutes=5)) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 4)) + + @classscope(attr='o') + def test_allows_reentry(self): + self.cl.allow_entry_after_exit = True + self.cl.save() + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_ENTRY, datetime=now() - timedelta(minutes=5)) + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_EXIT, datetime=now() - timedelta(minutes=2)) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 4)) + + @classscope(attr='o') + def test_feature_disabled(self): + self.quota.release_after_exit = False + self.quota.save() + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_ENTRY, datetime=now() - timedelta(minutes=5)) + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_EXIT, datetime=now() - timedelta(minutes=2)) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 4)) + + @classscope(attr='o') + def test_checked_out(self): + self.op.checkins.create(list=self.cl, type=Checkin.TYPE_EXIT, datetime=now() - timedelta(minutes=5)) + self.assertEqual(self.item1.check_quotas(), (Quota.AVAILABILITY_OK, 4)) + + class BundleQuotaTestCase(BaseQuotaTestCase): def setUp(self): super().setUp()