Allow check-in lists to include unpaid orders

This commit is contained in:
Raphael Michel
2018-02-21 16:17:06 +01:00
parent 36585395f1
commit 3fbccf3f64
15 changed files with 220 additions and 38 deletions
+10 -1
View File
@@ -21,11 +21,12 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the check-in list
name string The internal name of the check-in list
all_products boolean If ``True``, the check-in lists contains tickets of all products in this event. The ``limit_products`` field is ignored in this case.
all_products boolean If ``true``, the check-in lists contains tickets of all products in this event. The ``limit_products`` field is ignored in this case.
limit_products list of integers List of item IDs to include in this list.
subevent integer ID of the date inside an event series this list belongs to (or ``null``).
position_count integer Number of tickets that match this list (read-only).
checkin_count integer Number of check-ins performed on this list (read-only).
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
===================================== ========================== =======================================================
.. versionchanged:: 1.10
@@ -36,6 +37,10 @@ checkin_count integer Number of check
The ``positions`` endpoints have been added.
.. versionchanged:: 1.13
The ``include_pending`` field has been added.
Endpoints
---------
@@ -71,6 +76,7 @@ Endpoints
"position_count": 456,
"all_products": true,
"limit_products": [],
"include_pending": false,
"subevent": null
}
]
@@ -111,6 +117,7 @@ Endpoints
"position_count": 456,
"all_products": true,
"limit_products": [],
"include_pending": false,
"subevent": null
}
@@ -156,6 +163,7 @@ Endpoints
"position_count": 0,
"all_products": false,
"limit_products": [1, 2],
"include_pending": false,
"subevent": null
}
@@ -204,6 +212,7 @@ Endpoints
"position_count": 42,
"all_products": false,
"limit_products": [1, 2],
"include_pending": false,
"subevent": null
}
+12
View File
@@ -15,6 +15,10 @@ uses to communicate with the pretix server.
negotiated live, so clients which do not need this feature can ignore the change. For this reason, the API version
has not been increased and is still set to 3.
.. versionchanged:: 1.13
Support for checking in unpaid tickets has been added.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
@@ -49,6 +53,9 @@ uses to communicate with the pretix server.
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
failure.
You **may** set the additional parameter ``ignore_unpaid`` to indicate that the check-in should be performed even
if the order is in pending state.
If questions are supported and required, you will receive a dictionary ``questions`` containing details on the
particular questions to ask. To answer them, just re-send your redemption request with additional parameters of
the form ``answer_<question>=<answer>``, e.g. ``answer_12=24``.
@@ -73,6 +80,7 @@ uses to communicate with the pretix server.
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"paid": true
}
}
@@ -97,6 +105,7 @@ uses to communicate with the pretix server.
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"paid": true
},
"questions": [
@@ -142,6 +151,7 @@ uses to communicate with the pretix server.
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"checkin_allowed": true,
"paid": true
}
}
@@ -201,6 +211,7 @@ uses to communicate with the pretix server.
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"paid": true
},
...
@@ -244,6 +255,7 @@ uses to communicate with the pretix server.
"attendee_name": "Peter Higgs",
"redeemed": false,
"attention": false,
"checkin_allowed": true,
"paid": true
},
...
+2 -1
View File
@@ -12,7 +12,8 @@ class CheckinListSerializer(I18nAwareModelSerializer):
class Meta:
model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count')
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending')
def validate(self, data):
data = super().validate(data)
+1 -1
View File
@@ -126,7 +126,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
qs = OrderPosition.objects.filter(
order__event=self.request.event,
order__status=Order.STATUS_PAID,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.checkinlist.include_pending else [Order.STATUS_PAID],
subevent=self.checkinlist.subevent
).annotate(
last_checked_in=Subquery(cqs)
@@ -0,0 +1,46 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.10 on 2018-02-20 10:31
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0080_question_ask_during_checkin'),
]
operations = [
migrations.AddField(
model_name='checkinlist',
name='include_pending',
field=models.BooleanField(default=False, verbose_name='Include pending orders'),
),
migrations.AlterField(
model_name='event',
name='presale_end',
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date. If you do not set this value, the presale will end after the end date of your event.', null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='logentry',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, to='pretixbase.Event'),
),
migrations.AlterField(
model_name='question',
name='ask_during_checkin',
field=models.BooleanField(default=False, help_text='This will only work if you handle your check-in with pretixdroid 1.8 or newer or pretixdesk 0.2 or newer.', verbose_name='Ask during check-in instead of in the ticket buying process'),
),
migrations.AlterField(
model_name='subevent',
name='presale_end',
field=models.DateTimeField(blank=True, help_text='Optional. No products will be sold after this date. If you do not set this value, the presale will end after the end date of your event.', null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='user',
name='require_2fa',
field=models.BooleanField(default=False, verbose_name='Two-factor authentification is required to log in'),
),
]
+64 -9
View File
@@ -14,6 +14,11 @@ class CheckinList(LoggedModel):
limit_products = models.ManyToManyField('Item', verbose_name=_("Limit to products"), blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True,
verbose_name=pgettext_lazy('subevent', 'Date'))
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
default=False,
help_text=_('With this option, people will be able to check in even if the '
'order have not been paid. This only works with pretixdesk '
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
@staticmethod
def annotate_with_numbers(qs, event):
@@ -29,7 +34,7 @@ class CheckinList(LoggedModel):
# position and to the list in question. Then, we check that it also belongs to the
# correct subevent (just to be sure) and aggregate over lists (so, over everything,
# since we filtered by lists).
cqs = Checkin.objects.filter(
cqs_paid = Checkin.objects.filter(
position__order__event=event,
position__order__status=Order.STATUS_PAID,
list=OuterRef('pk')
@@ -41,12 +46,24 @@ class CheckinList(LoggedModel):
).order_by().values('list').annotate(
c=Count('*')
).values('c')
cqs_paid_and_pending = Checkin.objects.filter(
position__order__event=event,
position__order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
list=OuterRef('pk')
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(position__subevent=OuterRef('subevent'))
| (Q(position__subevent__isnull=True))
).order_by().values('list').annotate(
c=Count('*')
).values('c')
# Now for the hard part: getting all order positions that contribute to this list. This
# requires us to use TWO subqueries. The first one, pqs_all, will only be used for check-in
# lists that contain all the products of the event. This is the simpler one, it basically
# looks like the check-in counter above.
pqs_all = OrderPosition.objects.filter(
pqs_all_paid = OrderPosition.objects.filter(
order__event=event,
order__status=Order.STATUS_PAID,
).filter(
@@ -57,13 +74,24 @@ class CheckinList(LoggedModel):
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
pqs_all_paid_and_pending = OrderPosition.objects.filter(
order__event=event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING]
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
# Now we need a subquery for the case of checkin lists that are limited to certain
# products. We cannot use OuterRef("limit_products") since that would do a cross-product
# with the products table and we'd get duplicate rows in the output with different annotations
# on them, which isn't useful at all. Therefore, we need to add a second layer of subqueries
# to retrieve all of those items and then check if the item_id is IN this subquery result.
pqs_limited = OrderPosition.objects.filter(
pqs_limited_paid = OrderPosition.objects.filter(
order__event=event,
order__status=Order.STATUS_PAID,
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
@@ -75,17 +103,44 @@ class CheckinList(LoggedModel):
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
pqs_limited_paid_and_pending = OrderPosition.objects.filter(
order__event=event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
# Finally, we put all of this together. We force empty subquery aggregates to 0 by using Coalesce()
# and decide which subquery to use for this row. In the end, we compute an integer percentage in case
# we want to display a progress bar.
return qs.annotate(
checkin_count=Coalesce(Subquery(cqs, output_field=models.IntegerField()), 0),
position_count=Coalesce(Case(
When(all_products=True, then=Subquery(pqs_all, output_field=models.IntegerField())),
default=Subquery(pqs_limited, output_field=models.IntegerField()),
output_field=models.IntegerField()
), 0)
checkin_count=Coalesce(
Case(
When(include_pending=True, then=Subquery(cqs_paid_and_pending, output_field=models.IntegerField())),
default=Subquery(cqs_paid, output_field=models.IntegerField()),
output_field=models.IntegerField()
),
0
),
position_count=Coalesce(
Case(
When(all_products=True, include_pending=False,
then=Subquery(pqs_all_paid, output_field=models.IntegerField())),
When(all_products=True, include_pending=True,
then=Subquery(pqs_all_paid_and_pending, output_field=models.IntegerField())),
When(all_products=False, include_pending=False,
then=Subquery(pqs_limited_paid, output_field=models.IntegerField())),
default=Subquery(pqs_limited_paid_and_pending, output_field=models.IntegerField()),
output_field=models.IntegerField()
),
0
)
).annotate(
percent=Case(
When(position_count__gt=0, then=F('checkin_count') * 100 / F('position_count')),
+2 -1
View File
@@ -36,7 +36,8 @@ class CheckinListForm(forms.ModelForm):
'name',
'all_products',
'limit_products',
'subevent'
'subevent',
'include_pending'
]
widgets = {
'limit_products': forms.CheckboxSelectMultiple(attrs={
@@ -86,6 +86,9 @@
</td>
<td>
<strong><a href="{% url "control:event.order" event=request.event.slug organizer=request.event.organizer.slug code=e.order.code %}">{{ e.order.code }}</a></strong>
{% if e.order.status == "n" %}
<span class="label label-warning">{% trans "unpaid" %}</span>
{% endif %}
</td>
<td>{{ e.item.name }}{% if e.variation %} {{ e.variation }}{% endif %}</td>
<td>{{ e.order.email }}</td>
@@ -23,6 +23,7 @@
{% if form.subevent %}
{% bootstrap_field form.subevent layout="control" %}
{% endif %}
{% bootstrap_field form.include_pending layout="control" %}
<legend>{% trans "Products" %}</legend>
<p>
{% blocktrans trimmed %}
+1 -1
View File
@@ -35,7 +35,7 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, ListView):
qs = OrderPosition.objects.filter(
order__event=self.request.event,
order__status=Order.STATUS_PAID,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.list.include_pending else [Order.STATUS_PAID],
subevent=self.list.subevent
).annotate(
last_checked_in=Subquery(cqs)
+3 -9
View File
@@ -37,12 +37,6 @@ class BaseCheckinList(BaseExporter):
label=_('Include QR-code secret'),
required=False
)),
('paid_only',
forms.BooleanField(
label=_('Only paid orders'),
initial=True,
required=False
)),
('sort',
forms.ChoiceField(
label=_('Sort by'),
@@ -182,7 +176,7 @@ class PDFCheckinList(ReportlabExportMixin, BaseCheckinList):
elif form_data['sort'] == 'code':
qs = qs.order_by('order__code')
if form_data['paid_only']:
if not cl.include_pending:
qs = qs.filter(order__status=Order.STATUS_PAID)
else:
qs = qs.filter(order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING))
@@ -267,7 +261,7 @@ class CSVCheckinList(BaseCheckinList):
headers = [
_('Order code'), _('Attendee name'), _('Product'), _('Price'), _('Checked in')
]
if form_data['paid_only']:
if not cl.include_pending:
qs = qs.filter(order__status=Order.STATUS_PAID)
else:
qs = qs.filter(order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING))
@@ -303,7 +297,7 @@ class CSVCheckinList(BaseCheckinList):
date_format(last_checked_in.astimezone(self.event.timezone), 'SHORT_DATETIME_FORMAT')
if last_checked_in else ''
]
if not form_data['paid_only']:
if cl.include_pending:
row.append(_('Yes') if op.order.status == Order.STATUS_PAID else _('No'))
if form_data['secrets']:
row.append(op.secret)
+30 -14
View File
@@ -122,7 +122,6 @@ class ConfigView(EventPermissionRequiredMixin, TemplateView):
class ApiView(View):
@method_decorator(csrf_exempt)
def dispatch(self, request, **kwargs):
try:
@@ -156,7 +155,6 @@ class ApiView(View):
class ApiRedeemView(ApiView):
def _save_answers(self, op, answers, given_answers):
for q, a in given_answers.items():
if not a:
@@ -193,6 +191,7 @@ class ApiRedeemView(ApiView):
def post(self, request, **kwargs):
secret = request.POST.get('secret', '!INVALID!')
force = request.POST.get('force', 'false') in ('true', 'True')
ignore_unpaid = request.POST.get('ignore_unpaid', 'false') in ('true', 'True')
nonce = request.POST.get('nonce')
response = {
'version': API_VERSION
@@ -237,23 +236,26 @@ class ApiRedeemView(ApiView):
self._save_answers(op, answers, given_answers)
if not self.config.list.all_products and op.item_id not in [i.pk for i in self.config.list.limit_products.all()]:
if not self.config.list.all_products and op.item_id not in [i.pk for i in
self.config.list.limit_products.all()]:
response['status'] = 'error'
response['reason'] = 'product'
elif not self.config.all_items and op.item_id not in [i.pk for i in self.config.items.all()]:
response['status'] = 'error'
response['reason'] = 'product'
elif op.order.status != Order.STATUS_PAID and not force and not (
ignore_unpaid and self.config.list.include_pending and op.order.status == Order.STATUS_PENDING
):
response['status'] = 'error'
response['reason'] = 'unpaid'
elif require_answers and not force and request.POST.get('questions_supported'):
response['status'] = 'incomplete'
response['questions'] = require_answers
elif op.order.status == Order.STATUS_PAID or force:
else:
ci, created = Checkin.objects.get_or_create(position=op, list=self.config.list, defaults={
'datetime': dt,
'nonce': nonce,
})
else:
response['status'] = 'error'
response['reason'] = 'unpaid'
if 'status' not in response:
if created or (nonce and nonce == ci.nonce):
@@ -282,7 +284,8 @@ class ApiRedeemView(ApiView):
'list': self.config.list.pk
})
response['data'] = serialize_op(op, redeemed=op.order.status == Order.STATUS_PAID or force)
response['data'] = serialize_op(op, redeemed=op.order.status == Order.STATUS_PAID or force,
clist=self.config.list)
except OrderPosition.DoesNotExist:
response['status'] = 'error'
@@ -310,7 +313,7 @@ def serialize_question(q, items=False):
return d
def serialize_op(op, redeemed):
def serialize_op(op, redeemed, clist):
name = op.attendee_name
if not name and op.addon_to:
name = op.addon_to.attendee_name
@@ -319,6 +322,13 @@ def serialize_op(op, redeemed):
name = op.order.invoice_address.name
except:
pass
checkin_allowed = (
op.order.status == Order.STATUS_PAID
or (
op.order.status == Order.STATUS_PENDING
and clist.include_pending
)
)
return {
'secret': op.secret,
'order': op.order.code,
@@ -330,6 +340,7 @@ def serialize_op(op, redeemed):
'attention': op.item.checkin_attention,
'redeemed': redeemed,
'paid': op.order.status == Order.STATUS_PAID,
'checkin_allowed': checkin_allowed
}
@@ -371,7 +382,7 @@ class ApiSearchView(ApiView):
| Q(order__invoice_address__name__icontains=query)
)[:25]
response['results'] = [serialize_op(op, bool(op.last_checked_in)) for op in ops]
response['results'] = [serialize_op(op, bool(op.last_checked_in), self.config.list) for op in ops]
else:
response['results'] = []
@@ -393,7 +404,8 @@ class ApiDownloadView(ApiView):
qs = OrderPosition.objects.filter(
order__event=self.event,
order__status=Order.STATUS_PAID,
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if self.config.list.include_pending else
[]),
subevent=self.config.list.subevent
).annotate(
last_checked_in=Subquery(cqs)
@@ -405,7 +417,7 @@ class ApiDownloadView(ApiView):
if not self.config.all_items:
qs = qs.filter(item__in=self.config.items.all())
response['results'] = [serialize_op(op, bool(op.last_checked_in)) for op in qs]
response['results'] = [serialize_op(op, bool(op.last_checked_in), self.config.list) for op in qs]
questions = self.event.questions.filter(ask_during_checkin=True).prefetch_related('items', 'options')
response['questions'] = [serialize_question(q, items=True) for q in questions]
@@ -417,11 +429,15 @@ class ApiStatusView(ApiView):
cqs = Checkin.objects.filter(
position__order__event=self.event, position__subevent=self.subevent,
position__order__status=Order.STATUS_PAID,
position__order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if
self.config.list.include_pending else []),
list=self.config.list
)
pqs = OrderPosition.objects.filter(
order__event=self.event, order__status=Order.STATUS_PAID, subevent=self.subevent,
order__event=self.event,
order__status__in=[Order.STATUS_PAID] + ([Order.STATUS_PENDING] if self.config.list.include_pending else
[]),
subevent=self.subevent,
)
if not self.config.list.all_products:
pqs = pqs.filter(item__in=self.config.list.limit_products.values_list('id', flat=True))
+1
View File
@@ -111,6 +111,7 @@ TEST_LIST_RES = {
"limit_products": [],
"position_count": 0,
"checkin_count": 0,
"include_pending": False,
"subevent": None
}
+21 -1
View File
@@ -1116,6 +1116,9 @@ class CheckinListTestCase(TestCase):
cls.cl_all = cls.event.checkin_lists.create(
name='All', all_products=True
)
cls.cl_all_pending = cls.event.checkin_lists.create(
name='Z Pending', all_products=True, include_pending=True
)
cls.cl_both = cls.event.checkin_lists.create(
name='Both', all_products=False
)
@@ -1152,9 +1155,23 @@ class CheckinListTestCase(TestCase):
op2.checkins.create(list=cls.cl_tickets)
op3.checkins.create(list=cls.cl_both)
o = Order.objects.create(
code='FOO', event=cls.event, email='dummy@dummy.test',
status=Order.STATUS_PENDING,
datetime=now(), expires=now() + timedelta(days=10),
total=Decimal("30"), payment_provider='banktransfer', locale='en'
)
op4 = OrderPosition.objects.create(
order=o,
item=cls.item2,
variation=None,
price=Decimal("6"),
)
op4.checkins.create(list=cls.cl_all_pending)
def test_annotated(self):
lists = list(CheckinList.annotate_with_numbers(self.event.checkin_lists.order_by('name'), self.event))
assert lists == [self.cl_all, self.cl_both, self.cl_tickets]
assert lists == [self.cl_all, self.cl_both, self.cl_tickets, self.cl_all_pending]
assert lists[0].checkin_count == 0
assert lists[0].position_count == 3
assert lists[0].percent == 0
@@ -1164,6 +1181,9 @@ class CheckinListTestCase(TestCase):
assert lists[2].checkin_count == 1
assert lists[2].position_count == 2
assert lists[2].percent == 50
assert lists[3].checkin_count == 1
assert lists[3].position_count == 4
assert lists[3].percent == 25
@pytest.mark.django_db
@@ -172,6 +172,29 @@ def test_require_paid(client, env):
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'error'
assert jdata['reason'] == 'unpaid'
assert jdata['data']['checkin_allowed'] is False
@pytest.mark.django_db
def test_check_in_pending(client, env):
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
env[2].status = Order.STATUS_PENDING
env[2].save()
env[5].include_pending = True
env[5].save()
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'error'
assert jdata['reason'] == 'unpaid'
assert jdata['data']['checkin_allowed'] is True
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234', 'ignore_unpaid': 'true'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'ok'
@pytest.mark.django_db