New pretixdroid configuration system

This commit is contained in:
Raphael Michel
2017-08-29 23:10:59 +02:00
parent 43b5140754
commit 631cded0d6
13 changed files with 658 additions and 190 deletions

View File

@@ -0,0 +1,23 @@
from django import forms
from pretix.plugins.pretixdroid.models import AppConfiguration
class AppConfigurationForm(forms.ModelForm):
class Meta:
model = AppConfiguration
fields = ('all_items', 'items', 'subevent', 'show_info', 'allow_search')
widgets = {
'items': forms.CheckboxSelectMultiple(attrs={
'data-inverse-dependency': '#id_all_items'
}),
}
def __init__(self, **kwargs):
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']

View File

@@ -0,0 +1,48 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.4 on 2017-08-29 13:08
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
def runfwd(app, schema_editor):
EventSettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
AppConfiguration = app.get_model('pretixdroid', 'AppConfiguration')
for setting in EventSettingsStore.objects.filter(key='pretixdroid_key'):
AppConfiguration.objects.create(
event=setting.object,
key=setting.value,
all_items=True
)
setting.delete()
class Migration(migrations.Migration):
initial = True
dependencies = [
('pretixbase', '0074_auto_20170825_1258'),
('pretixdroid', '0002_auto_20161208_1644'),
]
operations = [
migrations.CreateModel(
name='AppConfiguration',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('key', models.CharField(db_index=True, max_length=190, unique=True)),
('all_items', models.BooleanField(default=True)),
('allow_search', models.BooleanField(default=True)),
('show_info', models.BooleanField(default=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='pretixbase.Event')),
('items', models.ManyToManyField(blank=True, to='pretixbase.Item')),
('subevent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent')),
],
),
migrations.RunPython(
runfwd, migrations.RunPython.noop
)
]

View File

@@ -0,0 +1,27 @@
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 _
class AppConfiguration(models.Model):
event = models.ForeignKey('pretixbase.Event')
key = models.CharField(max_length=190, unique=True, 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.'))
def save(self, **kwargs):
if not self.key:
self.key = get_random_string(
length=32, allowed_chars=string.ascii_uppercase + string.ascii_lowercase + string.digits
)
return super().save(**kwargs)

View File

@@ -8,53 +8,81 @@
<p>{% blocktrans trimmed %}
pretixdroid is an Android app that you can use to control tickets at the entrance of your event.
{% endblocktrans %}</p>
<h2>{% trans "App download" %}</h2>
<p>
<a href="http://play.google.com/store/apps/details?id=eu.pretix.pretixdroid">
<img src="{% static "pretixplugins/pretixdroid/play_store_en.png" %}" alt="
{% trans "Download the app from the Google Play Store" %}" height="70">
</a>
</p>
<p>
<small>
{% blocktrans trimmed %}
Android, Google Play and the Google Play logo are trademarks of Google Inc.
{% endblocktrans %}
</small>
</p>
<h2>{% trans "App configuration" %}</h2>
<h2>{% trans "Create app configuration" %}</h2>
<p>
{% blocktrans trimmed %}
If you start the app for the first time, it will request that you scan the following code.
The code tells the app all it needs about your event.
To start scanning tickets with our Android app, first create a configuration code here:
{% endblocktrans %}
</p>
{% if request.event.has_subevents %}
<form class="form-inline helper-display-inline" action="" method="get">
<p>
{% if request.event.has_subevents %}
<select name="subevent" class="form-control">
<option value="">{% trans "Choose date" context "subevent" %}</option>
{% for se in request.event.subevents.all %}
<option value="{{ se.id }}"
{% if request.GET.subevent|add:0 == se.id %}selected="selected"{% endif %}>
{{ se.name }} {{ se.get_date_range_display }}
</option>
{% endfor %}
</select>
{% endif %}
<button class="btn btn-primary" type="submit">{% trans "Show configuration" %}</button>
</p>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form_errors add_form %}
{% 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">
{% trans "Create" %}
</button>
</div>
</div>
</form>
{% if configs %}
<h2>{% trans "Existing app configurations" %}</h2>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<table class="table">
<thead>
<tr>
<th>{% trans "ID" %}</th>
{% if request.event.has_subevents %}
<th>{% trans "Date" context "subevent" %}</th>
{% endif %}
<th>{% trans "Items" %}</th>
<th>{% trans "Show info" %}</th>
<th>{% trans "Allow search" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% 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>
{% if ac.all_items %}
{% trans "All" %}
{% else %}
{% for item in ac.items.all %}
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item.name }}</a>
{% if loop.revindex0 > 0 %}<br>{% endif %}
{% endfor %}
{% endif %}
</td>
<td>{% if ac.show_info %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
<td>{% if ac.allow_search %}{% trans "Yes" %}{% else %}{% trans "No" %}{% endif %}</td>
<td class="text-right">
<a href="{% url "plugins:pretixdroid:config.code" organizer=request.event.organizer.slug event=request.event.slug config=ac.pk %}" class="btn btn-default">
<span class="fa fa-qrcode"></span> {% trans "Show QR code" %}
</a>
<button class="btn btn-danger" name="delete" value="{{ ac.pk }}">
<span class="fa fa-trash"></span>
</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endif %}
{% if not request.event.has_subevents or subevent %}
<div id="qrcodeCanvas"></div>
<a href="?flush_key=1" class="btn btn-default">{% trans "Reset authentication token" %}</a>
<script type="text/json" id="qrdata">
{{ qrdata|safe }}
</script>
{% endif %}
<script type="text/javascript" src="{% static "pretixplugins/pretixdroid/pretixdroid.js" %}"></script>
{% endblock %}

View File

@@ -0,0 +1,36 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load staticfiles %}
{% block title %}{% trans "pretixdroid configuration" %}{% endblock %}
{% block content %}
<h1>
{% trans "pretixdroid configuration" %}
<a href="{% url "plugins:pretixdroid:config" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default">
{% trans "Back to overview" %}
</a>
</h1>
<h2>{% trans "1. Download app" %}</h2>
<p>
<a href="http://play.google.com/store/apps/details?id=eu.pretix.pretixdroid">
<img src="{% static "pretixplugins/pretixdroid/play_store_en.png" %}" alt="
{% trans "Download the app from the Google Play Store" %}" height="70">
</a>
</p>
<p>
<small>
{% blocktrans trimmed %}
Android, Google Play and the Google Play logo are trademarks of Google Inc.
{% endblocktrans %}
</small>
</p>
<h2>{% trans "2. Scan code" %}</h2>
<div id="qrcodeCanvas"></div>
<script type="text/json" id="qrdata">
{{ qrdata|safe }}
</script>
<h2>{% trans "3. Start scanning tickets" %}</h2>
<script type="text/javascript" src="{% static "pretixplugins/pretixdroid/pretixdroid.js" %}"></script>
{% endblock %}

View File

@@ -14,8 +14,10 @@ pretixdroid_api_patterns = [
]
urlpatterns = [
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/pretixdroid/', views.ConfigView.as_view(),
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/pretixdroid/$', views.ConfigView.as_view(),
name='config'),
url(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/pretixdroid/(?P<config>\d+)/$',
views.ConfigCodeView.as_view(), name='config.code'),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/(?P<subevent>\d+)/',
include(pretixdroid_api_patterns)),
url(r'^pretixdroid/api/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include(pretixdroid_api_patterns)),

View File

@@ -1,17 +1,19 @@
import json
import logging
import string
import dateutil.parser
from django.contrib import messages
from django.db import transaction
from django.db.models import Count, Q
from django.http import (
HttpResponseForbidden, HttpResponseNotFound, JsonResponse,
)
from django.shortcuts import get_object_or_404
from django.utils.crypto import get_random_string
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView, View
@@ -22,44 +24,93 @@ from pretix.helpers.urls import build_absolute_uri
from pretix.multidomain.urlreverse import (
build_absolute_uri as event_absolute_uri,
)
from pretix.plugins.pretixdroid.forms import AppConfigurationForm
from pretix.plugins.pretixdroid.models import AppConfiguration
logger = logging.getLogger('pretix.plugins.pretixdroid')
API_VERSION = 3
class ConfigCodeView(EventPermissionRequiredMixin, TemplateView):
template_name = 'pretixplugins/pretixdroid/configuration_code.html'
permission = 'can_change_orders'
def get(self, request, **kwargs):
try:
self.object = self.request.event.appconfiguration_set.get(pk=kwargs.get("config"))
except AppConfiguration.DoesNotExist:
messages.error(request, _('The selected configuration does not exist.'))
return redirect(reverse('plugins:pretixdroid:config', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
}))
return super().get(request, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})
if self.object.subevent:
url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'subevent': self.object.subevent.pk
})
ctx['qrdata'] = json.dumps({
'version': API_VERSION,
'url': url[:-7], # the slice removes the redeem/ part at the end
'key': self.object.key,
'allow_search': self.object.allow_search,
'show_info': self.object.show_info
})
return ctx
class ConfigView(EventPermissionRequiredMixin, TemplateView):
template_name = 'pretixplugins/pretixdroid/configuration.html'
permission = 'can_change_orders'
@cached_property
def add_form(self):
return AppConfigurationForm(
event=self.request.event,
instance=AppConfiguration(event=self.request.event),
data=self.request.POST if self.request.method == "POST" and "add" in self.request.POST else None
)
def post(self, request, *args, **kwargs):
if "add" in self.request.POST and self.add_form.is_valid():
self.add_form.save()
self.request.event.log_action('pretix.plugins.pretixdroid.config.added', user=self.request.user,
data=dict(self.add_form.cleaned_data))
return redirect(reverse('plugins:pretixdroid:config.code', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'config': self.add_form.instance.pk
}))
elif "delete" in self.request.POST:
try:
ac = self.request.event.appconfiguration_set.get(pk=request.POST.get("delete"))
self.request.event.log_action('pretix.plugins.pretixdroid.config.deleted', user=self.request.user,
data={'id': ac.pk})
ac.delete()
messages.success(request, _('The selected configuration has been deleted.'))
except AppConfiguration.DoesNotExist:
messages.error(request, _('The selected configuration does not exist.'))
return redirect(reverse('plugins:pretixdroid:config', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
}))
else:
return self.get(request, *args, **kwargs)
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
key = self.request.event.settings.get('pretixdroid_key')
if not key or 'flush_key' in self.request.GET:
key = get_random_string(length=32,
allowed_chars=string.ascii_uppercase + string.ascii_lowercase + string.digits)
self.request.event.settings.set('pretixdroid_key', key)
subevent = None
url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug
})
if self.request.event.has_subevents:
if self.request.GET.get('subevent'):
subevent = get_object_or_404(SubEvent, event=self.request.event, pk=self.request.GET['subevent'])
url = build_absolute_uri('plugins:pretixdroid:api.redeem', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
'subevent': subevent.pk
})
ctx['subevent'] = subevent
ctx['qrdata'] = json.dumps({
'version': API_VERSION,
'url': url[:-7], # the slice removes the redeem/ part at the end
'key': key,
})
ctx['add_form'] = self.add_form
ctx['configs'] = self.request.event.appconfiguration_set.prefetch_related('items')
return ctx
@@ -75,13 +126,16 @@ class ApiView(View):
except Event.DoesNotExist:
return HttpResponseNotFound('Unknown event')
if (not self.event.settings.get('pretixdroid_key')
or self.event.settings.get('pretixdroid_key') != request.GET.get('key', '-unset-')):
try:
self.config = self.event.appconfiguration_set.get(key=request.GET.get("key", "-unset-"))
except AppConfiguration.DoesNotExist:
return HttpResponseForbidden('Invalid key')
self.subevent = None
if self.event.has_subevents:
if 'subevent' in kwargs:
if self.config.subevent:
self.subevent = self.config.subevent
elif 'subevent' in kwargs:
self.subevent = get_object_or_404(SubEvent, event=self.event, pk=kwargs['subevent'])
else:
return HttpResponseForbidden('No subevent selected.')
@@ -112,7 +166,10 @@ class ApiRedeemView(ApiView):
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
order__event=self.event, secret=secret, subevent=self.subevent
)
if op.order.status == Order.STATUS_PAID or force:
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={
'datetime': dt,
'nonce': nonce,
@@ -186,14 +243,20 @@ class ApiSearchView(ApiView):
}
if len(query) >= 4:
ops = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address').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]
qs = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address')
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]
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]
response['results'] = [serialize_op(op, bool(op.checkin_cnt)) for op in ops]
else:
@@ -210,7 +273,11 @@ class ApiDownloadView(ApiView):
ops = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').filter(
Q(order__event=self.event) & Q(subevent=self.subevent)
).annotate(checkin_cnt=Count('checkins'))
)
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]
return JsonResponse(response)