forked from CGM_Public/pretix_original
Allow to release quota after exit scans
This commit is contained in:
@@ -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],
|
||||
|
||||
@@ -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
|
||||
|
||||
18
src/pretix/base/migrations/0155_quota_release_after_exit.py
Normal file
18
src/pretix/base/migrations/0155_quota_release_after_exit.py
Normal file
@@ -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),
|
||||
),
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
{% blocktrans with name=quota.name %}Quota: {{ name }}{% endblocktrans %}
|
||||
{% if 'can_change_items' in request.eventpermset %}
|
||||
<a href="{% url "control:event.items.quotas.edit" event=request.event.slug organizer=request.event.organizer.slug quota=quota.pk %}"
|
||||
class="btn btn-default">
|
||||
class="btn btn-default">
|
||||
<span class="fa fa-edit"></span>
|
||||
{% trans "Edit quota" %}
|
||||
</a>
|
||||
@@ -34,7 +34,8 @@
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-warning">
|
||||
<button type="submit" class="btn btn-primary pull-right flip" name="reopen" value="true">{% trans "Open quota" %}</button>
|
||||
<button type="submit" class="btn btn-primary pull-right flip" name="reopen"
|
||||
value="true">{% trans "Open quota" %}</button>
|
||||
{% 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." %}
|
||||
<div class="clearfix"></div>
|
||||
</div>
|
||||
@@ -65,9 +66,11 @@
|
||||
{% if row.strong %}</strong>{% endif %}
|
||||
</div>
|
||||
<div class="col-xs-3 flip text-right">
|
||||
{% if row.strong %}<strong>{% endif %}
|
||||
{% if not row.strong %}–{% endif %} {{ row.value }}
|
||||
{% if row.strong %}</strong>{% endif %}
|
||||
{% if row.strong %}
|
||||
<strong>{{ row.value }}</strong>
|
||||
{% else %}
|
||||
{% if row.value >= 0 %}– {{ row.value }}{% else %}+ {{ row.value_abs }}{% endif %}
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
|
||||
@@ -40,6 +40,7 @@
|
||||
<fieldset>
|
||||
<legend>{% trans "Advanced options" %}</legend>
|
||||
{% bootstrap_field form.close_when_sold_out layout="control" %}
|
||||
{% bootstrap_field form.release_after_exit layout="control" %}
|
||||
</fieldset>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
|
||||
@@ -422,6 +422,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" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -449,6 +450,7 @@
|
||||
<div class="panel-body form-horizontal">
|
||||
{% 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" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
|
||||
@@ -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" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
@@ -133,6 +134,7 @@
|
||||
<div class="panel-body form-horizontal">
|
||||
{% 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" %}
|
||||
</div>
|
||||
</div>
|
||||
{% endescapescript %}
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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()
|
||||
]
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user