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
+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