Fix #515 -- Add check-in lists (#693)

* Data model and migration

* Some backwards compatibility

* CRUD for checkin lists

* Show and perform checkins

* Correct numbers in table and dashboard widget

* event creation and cloning

* Allow to link specific exports and pass options per query

* Play with the CSV export

* PDF export

* Collapse exports by default

* Improve PDF exporter

* Addon stuff

* Subevent stuff, pretixdroid tests

* pretixdroid tests

* Add CRUD API

* Test compatibility

* Fix test

* DB-independent sorting behavior

* Add CRUD and coyp tests

* Re-enable pretixdroid plugin

* pretixdroid config

* Tests & fixes
This commit is contained in:
Raphael Michel
2017-12-04 18:12:23 +01:00
committed by GitHub
parent f1be7ed69d
commit 353dce789d
58 changed files with 2402 additions and 608 deletions

View File

@@ -12,6 +12,7 @@ class PretixdroidApp(AppConfig):
name = _("pretixdroid API")
author = _("the pretix team")
version = version
visible = True
description = _("This plugin allows you to use the pretixdroid Android app for your event.")
def ready(self):

View File

@@ -6,7 +6,7 @@ from pretix.plugins.pretixdroid.models import AppConfiguration
class AppConfigurationForm(forms.ModelForm):
class Meta:
model = AppConfiguration
fields = ('all_items', 'items', 'subevent', 'show_info', 'allow_search')
fields = ('all_items', 'items', 'list', 'show_info', 'allow_search')
widgets = {
'items': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_items'
@@ -17,7 +17,4 @@ class AppConfigurationForm(forms.ModelForm):
self.event = kwargs.pop('event')
super().__init__(**kwargs)
self.fields['items'].queryset = self.event.items.all()
if self.event.has_subevents:
self.fields['subevent'].queryset = self.event.subevents.all()
else:
del self.fields['subevent']
self.fields['list'].queryset = self.event.checkin_lists.all()

View File

@@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.2 on 2017-11-24 16:57
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
def assign_checkin_lists(apps, schema_editor):
AppConfiguration = apps.get_model('pretixdroid', 'AppConfiguration')
for ac in AppConfiguration.objects.all():
cl = ac.event.checkin_lists.get_or_create(subevent=ac.subevent, all_products=True, defaults={
'name': ac.subevent.name if ac.subevent else 'Default'
})[0]
ac.list = cl
ac.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0077_auto_20171124_1629'),
('pretixdroid', '0003_appconfiguration'),
]
operations = [
migrations.AddField(
model_name='appconfiguration',
name='list',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to='pretixbase.CheckinList'),
),
migrations.AlterField(
model_name='appconfiguration',
name='all_items',
field=models.BooleanField(default=True, verbose_name='Can scan all products'),
),
migrations.AlterField(
model_name='appconfiguration',
name='allow_search',
field=models.BooleanField(default=True,
help_text='If disabled, the device can not search for attendees by name. pretixdroid 1.6 or newer only.',
verbose_name='Search allowed'),
),
migrations.AlterField(
model_name='appconfiguration',
name='items',
field=models.ManyToManyField(blank=True, to='pretixbase.Item', verbose_name='Can scan these products'),
),
migrations.AlterField(
model_name='appconfiguration',
name='show_info',
field=models.BooleanField(default=True,
help_text='If disabled, the device can not see how many tickets exist and how many are already scanned. pretixdroid 1.6 or newer only.',
verbose_name='Show information'),
),
migrations.RunPython(
assign_checkin_lists,
migrations.RunPython.noop
),
migrations.RemoveField(
model_name='appconfiguration',
name='subevent',
),
migrations.AlterField(
model_name='appconfiguration',
name='list',
field=models.ForeignKey(blank=False, null=False, on_delete=django.db.models.deletion.CASCADE,
to='pretixbase.CheckinList'),
),
]

View File

@@ -2,7 +2,7 @@ import string
from django.db import models
from django.utils.crypto import get_random_string
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django.utils.translation import ugettext_lazy as _
class AppConfiguration(models.Model):
@@ -10,14 +10,19 @@ class AppConfiguration(models.Model):
key = models.CharField(max_length=190, db_index=True)
all_items = models.BooleanField(default=True, verbose_name=_('Can scan all products'))
items = models.ManyToManyField('pretixbase.Item', blank=True, verbose_name=_('Can scan these products'))
subevent = models.ForeignKey('pretixbase.SubEvent', null=True, blank=True,
verbose_name=pgettext_lazy('subevent', 'Date'))
show_info = models.BooleanField(default=True, verbose_name=_('Show information'),
help_text=_('If disabled, the device can not see how many tickets exist and how '
'many are already scanned. pretixdroid 1.6 or newer only.'))
allow_search = models.BooleanField(default=True, verbose_name=_('Search allowed'),
help_text=_('If disabled, the device can not search for attendees by name. '
'pretixdroid 1.6 or newer only.'))
list = models.ForeignKey(
'pretixbase.CheckinList', on_delete=models.CASCADE, verbose_name=_('Check-in list')
)
@property
def subevent(self):
return self.list.subevent
def save(self, **kwargs):
if not self.key:

View File

@@ -7,6 +7,7 @@ from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CheckinList
from pretix.base.signals import logentry_display
from pretix.control.signals import nav_event
@@ -43,25 +44,40 @@ def pretixcontrol_logentry_display(sender, logentry, **kwargs):
tz = pytz.timezone(sender.settings.timezone)
dt_formatted = date_format(dt.astimezone(tz), "SHORT_DATETIME_FORMAT")
if 'list' in data:
try:
checkin_list = sender.checkin_lists.get(pk=data.get('list')).name
except CheckinList.DoesNotExist:
checkin_list = _("(unknown)")
else:
checkin_list = _("(unknown)")
if data.get('first'):
if show_dt:
return _('Position #{posid} has been scanned at {datetime}.').format(
return _('Position #{posid} has been scanned at {datetime} for list "{list}".').format(
posid=data.get('positionid'),
datetime=dt_formatted
datetime=dt_formatted,
list=checkin_list
)
else:
return _('Position #{posid} has been scanned.').format(
posid=data.get('positionid')
return _('Position #{posid} has been scanned for list "{list}".').format(
posid=data.get('positionid'),
list=checkin_list
)
else:
if data.get('forced'):
return _(
'A scan for position #{posid} at {datetime} has been uploaded even though it has '
'A scan for position #{posid} at {datetime} for list "{list}" has been uploaded even though it has '
'been scanned already.'.format(
posid=data.get('positionid'),
datetime=dt_formatted
datetime=dt_formatted,
list=checkin_list
)
)
return _('Position #{posid} has been scanned and rejected because it has already been scanned before.'.format(
posid=data.get('positionid')
))
return _(
'Position #{posid} has been scanned and rejected because it has already been scanned before '
'on list "{list}".'.format(
posid=data.get('positionid'),
list=checkin_list
)
)

View File

@@ -19,13 +19,11 @@
<form action="?add" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors add_form %}
{% bootstrap_field add_form.list layout="horizontal" %}
{% bootstrap_field add_form.all_items layout="horizontal" %}
{% bootstrap_field add_form.items layout="horizontal" %}
{% bootstrap_field add_form.show_info layout="horizontal" %}
{% bootstrap_field add_form.allow_search layout="horizontal" %}
{% if add_form.subevent %}
{% bootstrap_field add_form.subevent layout="horizontal" %}
{% endif %}
<div class="form-group">
<div class="col-md-offset-3 col-md-9">
<button type="submit" class="btn btn-primary btn-save" name="add" value="1">
@@ -48,9 +46,7 @@
<thead>
<tr>
<th>{% trans "ID" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}</th>
{% endif %}
<th>{% trans "Check-in list" %}</th>
<th>{% trans "Items" %}</th>
<th>{% trans "Show info" %}</th>
<th>{% trans "Allow search" %}</th>
@@ -61,9 +57,9 @@
{% for ac in configs %}
<tr>
<td>{{ ac.key|slice:"0:8" }}…</td>
{% if request.event.has_subevents %}
<td>{% if ac.subevent %}{{ ac.subevent }}{% else %}{% trans "All" %}{% endif %}</td>
{% endif %}
<td>
<a href="{% url "control:event.orders.checkinlists.show" organizer=request.event.organizer.slug event=request.event.slug list=ac.list.id %}">{{ ac.list }}</a>
</td>
<td>
{% if ac.all_items %}
{% trans "All" %}

View File

@@ -4,7 +4,7 @@ import logging
import dateutil.parser
from django.contrib import messages
from django.db import transaction
from django.db.models import Count, Q
from django.db.models import Count, Max, OuterRef, Q, Subquery
from django.http import (
HttpResponseForbidden, HttpResponseNotFound, JsonResponse,
)
@@ -110,7 +110,7 @@ class ConfigView(EventPermissionRequiredMixin, TemplateView):
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['add_form'] = self.add_form
ctx['configs'] = self.request.event.appconfiguration_set.prefetch_related('items')
ctx['configs'] = self.request.event.appconfiguration_set.select_related('list').prefetch_related('items')
return ctx
@@ -133,8 +133,10 @@ class ApiView(View):
self.subevent = None
if self.event.has_subevents:
if self.config.subevent:
self.subevent = self.config.subevent
if self.config.list.subevent:
self.subevent = self.config.list.subevent
if 'subevent' in kwargs and kwargs['subevent'] != str(self.subevent.pk):
return HttpResponseForbidden('Invalid subevent selected.')
elif 'subevent' in kwargs:
self.subevent = get_object_or_404(SubEvent, event=self.event, pk=kwargs['subevent'])
else:
@@ -166,11 +168,14 @@ class ApiRedeemView(ApiView):
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
order__event=self.event, secret=secret, subevent=self.subevent
)
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'
if 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 or force:
ci, created = Checkin.objects.get_or_create(position=op, defaults={
ci, created = Checkin.objects.get_or_create(position=op, list=self.config.list, defaults={
'datetime': dt,
'nonce': nonce,
})
@@ -188,6 +193,7 @@ class ApiRedeemView(ApiView):
'first': True,
'forced': op.order.status != Order.STATUS_PAID,
'datetime': dt,
'list': self.config.list.pk
})
else:
if force:
@@ -201,6 +207,7 @@ class ApiRedeemView(ApiView):
'first': False,
'forced': force,
'datetime': dt,
'list': self.config.list.pk
})
response['data'] = serialize_op(op, redeemed=op.order.status == Order.STATUS_PAID or force)
@@ -243,22 +250,38 @@ class ApiSearchView(ApiView):
}
if len(query) >= 4:
qs = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
cqs = Checkin.objects.filter(
position_id=OuterRef('pk'),
list_id=self.config.list.pk
).order_by().values('position_id').annotate(
m=Max('datetime')
).values('m')
qs = OrderPosition.objects.filter(
order__event=self.event,
order__status=Order.STATUS_PAID,
subevent=self.config.list.subevent
).annotate(
last_checked_in=Subquery(cqs)
).select_related('item', 'variation', 'order', 'order__invoice_address', 'addon_to')
if not self.config.list.all_products:
qs = qs.filter(item__in=self.config.list.limit_products.values_list('id', flat=True))
if not self.config.all_items:
qs = qs.filter(item__in=self.config.items.all())
if not self.config.allow_search:
ops = qs.filter(
Q(order__event=self.event) & Q(secret__istartswith=query) & Q(subevent=self.subevent)
).annotate(checkin_cnt=Count('checkins'))[:25]
Q(secret__istartswith=query)
)[:25]
else:
ops = qs.filter(
Q(order__event=self.event)
& Q(
Q(secret__istartswith=query) | Q(attendee_name__icontains=query) | Q(order__code__istartswith=query)
| Q(order__invoice_address__name__icontains=query)
)
& Q(subevent=self.subevent)
).annotate(checkin_cnt=Count('checkins'))[:25]
Q(secret__istartswith=query) | Q(attendee_name__icontains=query) | Q(order__code__istartswith=query)
| Q(order__invoice_address__name__icontains=query)
)[:25]
response['results'] = [serialize_op(op, bool(op.checkin_cnt)) for op in ops]
response['results'] = [serialize_op(op, bool(op.last_checked_in)) for op in ops]
else:
response['results'] = []
@@ -271,25 +294,51 @@ class ApiDownloadView(ApiView):
'version': API_VERSION
}
ops = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').filter(
Q(order__event=self.event) & Q(subevent=self.subevent)
)
cqs = Checkin.objects.filter(
position_id=OuterRef('pk'),
list_id=self.config.list.pk
).order_by().values('position_id').annotate(
m=Max('datetime')
).values('m')
qs = OrderPosition.objects.filter(
order__event=self.event,
order__status=Order.STATUS_PAID,
subevent=self.config.list.subevent
).annotate(
last_checked_in=Subquery(cqs)
).select_related('item', 'variation', 'order', 'addon_to')
if not self.config.list.all_products:
qs = qs.filter(item__in=self.config.list.limit_products.values_list('id', flat=True))
if not self.config.all_items:
ops = ops.filter(item__in=self.config.items.all())
ops = ops.annotate(checkin_cnt=Count('checkins'))
response['results'] = [serialize_op(op, bool(op.checkin_cnt)) for op in ops]
qs = qs.filter(item__in=self.config.items.all())
response['results'] = [serialize_op(op, bool(op.last_checked_in)) for op in qs]
return JsonResponse(response)
class ApiStatusView(ApiView):
def get(self, request, **kwargs):
cqs = Checkin.objects.filter(
position__order__event=self.event, position__subevent=self.subevent,
position__order__status=Order.STATUS_PAID,
list=self.config.list
)
pqs = OrderPosition.objects.filter(
order__event=self.event, order__status=Order.STATUS_PAID, 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))
ev = self.subevent or self.event
response = {
'version': API_VERSION,
'event': {
'name': str(ev.name),
'list': self.config.list.name,
'slug': self.event.slug,
'organizer': {
'name': str(self.event.organizer),
@@ -301,45 +350,25 @@ class ApiStatusView(ApiView):
'timezone': self.event.settings.timezone,
'url': event_absolute_uri(self.event, 'presale:event.index')
},
'checkins': Checkin.objects.filter(
position__order__event=self.event, position__subevent=self.subevent
).count(),
'total': OrderPosition.objects.filter(
order__event=self.event, order__status=Order.STATUS_PAID, subevent=self.subevent
).count()
'checkins': cqs.count(),
'total': pqs.count()
}
op_by_item = {
p['item']: p['cnt']
for p in OrderPosition.objects.filter(
order__event=self.event,
order__status=Order.STATUS_PAID,
subevent=self.subevent
).order_by().values('item').annotate(cnt=Count('id'))
for p in pqs.order_by().values('item').annotate(cnt=Count('id'))
}
op_by_variation = {
p['variation']: p['cnt']
for p in OrderPosition.objects.filter(
order__event=self.event,
order__status=Order.STATUS_PAID,
subevent=self.subevent
).order_by().values('variation').annotate(cnt=Count('id'))
for p in pqs.order_by().values('variation').annotate(cnt=Count('id'))
}
c_by_item = {
p['position__item']: p['cnt']
for p in Checkin.objects.filter(
position__order__event=self.event,
position__order__status=Order.STATUS_PAID,
position__subevent=self.subevent
).order_by().values('position__item').annotate(cnt=Count('id'))
for p in cqs.order_by().values('position__item').annotate(cnt=Count('id'))
}
c_by_variation = {
p['position__variation']: p['cnt']
for p in Checkin.objects.filter(
position__order__event=self.event,
position__order__status=Order.STATUS_PAID,
position__subevent=self.subevent
).order_by().values('position__variation').annotate(cnt=Count('id'))
for p in cqs.order_by().values('position__variation').annotate(cnt=Count('id'))
}
response['items'] = []