diff --git a/doc/api/resources/checkinlists.rst b/doc/api/resources/checkinlists.rst new file mode 100644 index 0000000000..4b65e60976 --- /dev/null +++ b/doc/api/resources/checkinlists.rst @@ -0,0 +1,238 @@ +Check-in lists +============== + +Resource description +-------------------- + +You can create check-in lists that you can use e.g. at the entrance of your event to track who is coming and if they +actually bought a ticket. + +You can create multiple check-in lists to separate multiple parts of your event, for example if you have separate +entries for multiple ticket types. Different check-in lists are completely independent: If a ticket shows up on two +lists, it is valid once on every list. This might be useful if you run a festival with festival passes that allow +access to every or multiple performances as well as tickets only valid for single performances. + +The check-in list resource contains the following public fields: + +.. rst-class:: rest-resource-table + +===================================== ========================== ======================================================= +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. +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). +===================================== ========================== ======================================================= + +.. versionchanged:: 1.10 + + This resource has been added. + +Endpoints +--------- + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/ + + Returns a list of all check-in lists within a given event. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "count": 1, + "next": null, + "previous": null, + "results": [ + { + "id": 1, + "name": "Default list", + "checkin_count": 123, + "position_count": 456, + "all_products": true, + "limit_products": [], + "subevent": null + } + ] + } + + :query integer page: The page number in case of a multi-page result set, default is 1 + :query integer subevent: Only return check-in lists of the sub-event with the given ID + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(id)/ + + Returns information on one check-in list, identified by its ID. + + **Example request**: + + .. sourcecode:: http + + GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 1, + "name": "Default list", + "checkin_count": 123, + "position_count": 456, + "all_products": true, + "limit_products": [], + "subevent": null + } + + :param organizer: The ``slug`` field of the organizer to fetch + :param event: The ``slug`` field of the event to fetch + :param id: The ``id`` field of the check-in list to fetch + :statuscode 200: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + +.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/ + + Creates a new check-in list. + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content: application/json + + { + "name": "VIP entry", + "all_products": false, + "limit_products": [1, 2], + "subevent": null + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 2, + "name": "VIP entry", + "checkin_count": 0, + "position_count": 0, + "all_products": false, + "limit_products": [1, 2], + "subevent": null + } + + :param organizer: The ``slug`` field of the organizer of the event/item to create a list for + :param event: The ``slug`` field of the event to create a list for + :statuscode 201: no error + :statuscode 400: The list could not be created due to invalid submitted data. + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource. + +.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(id)/ + + Update a check-in list. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of + the resource, other fields will be resetted to default. With ``PATCH``, you only need to provide the fields that you + want to change. + + You can change all fields of the resource except the ``id`` field and the ``checkin_count`` and ``position_count`` + fields. + + **Example request**: + + .. sourcecode:: http + + PATCH /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + Content-Type: application/json + Content-Length: 94 + + { + "name": "Backstage", + } + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "id": 2, + "name": "Backstage", + "checkin_count": 23, + "position_count": 42, + "all_products": false, + "limit_products": [1, 2], + "subevent": null + } + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the list to modify + :statuscode 200: no error + :statuscode 400: The list could not be modified due to invalid submitted data + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource. + +.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/checkinlist/(id)/ + + Delete a check-in list. Note that this also deletes the information on all checkins performed via this list. + + **Example request**: + + .. sourcecode:: http + + DELETE /api/v1/organizers/bigevents/events/sampleconf/checkinlist/1/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + **Example response**: + + .. sourcecode:: http + + HTTP/1.1 204 No Content + Vary: Accept + + :param organizer: The ``slug`` field of the organizer to modify + :param event: The ``slug`` field of the event to modify + :param id: The ``id`` field of the check-in list to delete + :statuscode 204: no error + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource. diff --git a/doc/api/resources/index.rst b/doc/api/resources/index.rst index 7be1b885b8..81ccaa7b5e 100644 --- a/doc/api/resources/index.rst +++ b/doc/api/resources/index.rst @@ -15,4 +15,5 @@ Resources and endpoints orders invoices vouchers + checkinlists waitinglist diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py new file mode 100644 index 0000000000..2a6c405410 --- /dev/null +++ b/src/pretix/api/serializers/checkin.py @@ -0,0 +1,37 @@ +from django.utils.translation import ugettext as _ +from rest_framework import serializers +from rest_framework.exceptions import ValidationError + +from pretix.api.serializers.i18n import I18nAwareModelSerializer +from pretix.base.models import CheckinList + + +class CheckinListSerializer(I18nAwareModelSerializer): + checkin_count = serializers.IntegerField(read_only=True) + position_count = serializers.IntegerField(read_only=True) + + class Meta: + model = CheckinList + fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count') + + def validate(self, data): + data = super().validate(data) + event = self.context['event'] + + full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} + full_data.update(data) + + for item in full_data.get('limit_products'): + if event != item.event: + raise ValidationError(_('One or more items do not belong to this event.')) + + if event.has_subevents: + if not full_data.get('subevent'): + raise ValidationError(_('Subevent cannot be null for event series.')) + if event != full_data.get('subevent').event: + raise ValidationError(_('The subevent does not belong to this event.')) + else: + if full_data.get('subevent'): + raise ValidationError(_('The subevent does not belong to this event.')) + + return data diff --git a/src/pretix/api/templates/__init__.py b/src/pretix/api/templates/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/src/pretix/api/templates/rest_framework/api.html b/src/pretix/api/templates/rest_framework/api.html deleted file mode 100644 index d5df9817fc..0000000000 --- a/src/pretix/api/templates/rest_framework/api.html +++ /dev/null @@ -1,19 +0,0 @@ -{% extends "rest_framework/base.html" %} -{% load staticfiles %} -{% load compress %} - -{% block bootstrap_theme %} - {% compress css %} - - {% endcompress %} -{% endblock %} -{% block branding %} - pretix REST API -{% endblock %} -{% block description %} -
-{% endblock %} diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 4ad6833e04..1a8cbe89ea 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -4,7 +4,7 @@ from django.apps import apps from django.conf.urls import include, url from rest_framework import routers -from .views import event, item, order, organizer, voucher, waitinglist +from .views import checkin, event, item, order, organizer, voucher, waitinglist router = routers.DefaultRouter() router.register(r'organizers', organizer.OrganizerViewSet) @@ -24,6 +24,7 @@ event_router.register(r'orderpositions', order.OrderPositionViewSet) event_router.register(r'invoices', order.InvoiceViewSet) event_router.register(r'taxrules', event.TaxRuleViewSet) event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet) +event_router.register(r'checkinlists', checkin.CheckinListViewSet) # Force import of all plugins to give them a chance to register URLs with the router for app in apps.get_app_configs(): diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py new file mode 100644 index 0000000000..1f5f7da594 --- /dev/null +++ b/src/pretix/api/views/checkin.py @@ -0,0 +1,59 @@ +from django_filters.rest_framework import DjangoFilterBackend, FilterSet +from rest_framework import viewsets + +from pretix.api.serializers.checkin import CheckinListSerializer +from pretix.base.models import CheckinList +from pretix.base.models.organizer import TeamAPIToken + + +class CheckinListFilter(FilterSet): + class Meta: + model = CheckinList + fields = ['subevent'] + + +class CheckinListViewSet(viewsets.ModelViewSet): + serializer_class = CheckinListSerializer + queryset = CheckinList.objects.none() + filter_backends = (DjangoFilterBackend,) + filter_class = CheckinListFilter + permission = 'can_view_orders' + write_permission = 'can_change_event_settings' + + def get_queryset(self): + qs = self.request.event.checkin_lists.prefetch_related( + 'limit_products', + ) + qs = CheckinList.annotate_with_numbers(qs, self.request.event) + return qs + + def perform_create(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.checkinlist.added', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + return ctx + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.checkinlist.changed', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + data=self.request.data + ) + + def perform_destroy(self, instance): + instance.log_action( + 'pretix.event.checkinlist.deleted', + user=self.request.user, + api_token=(self.request.auth if isinstance(self.request.auth, TeamAPIToken) else None), + ) + super().perform_destroy(instance) diff --git a/src/pretix/base/migrations/0077_auto_20171124_1629.py b/src/pretix/base/migrations/0077_auto_20171124_1629.py new file mode 100644 index 0000000000..bbf6a49b29 --- /dev/null +++ b/src/pretix/base/migrations/0077_auto_20171124_1629.py @@ -0,0 +1,106 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.2 on 2017-11-24 16:29 +from __future__ import unicode_literals + +import django.core.validators +import django.db.models.deletion +from django.conf import settings +from django.db import migrations, models +from django.utils.translation import ugettext as _ + +import pretix.base.validators +from pretix.base.i18n import language + + +def create_checkin_lists(apps, schema_editor): + Event = apps.get_model('pretixbase', 'Event') + Checkin = apps.get_model('pretixbase', 'Checkin') + EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore') + for e in Event.objects.all(): + locale = EventSettingsStore.objects.filter(object=e, key='locale').first() + if locale: + locale = locale.value + else: + locale = settings.LANGUAGE_CODE + + if e.has_subevents: + for se in e.subevents.all(): + with language(locale): + cl = e.checkin_lists.create(name=se.name, subevent=se, all_products=True) + Checkin.objects.filter(position__subevent=se, position__order__event=e).update(list=cl) + else: + with language(locale): + cl = e.checkin_lists.create(name=_('Default list'), all_products=True) + Checkin.objects.filter(position__order__event=e).update(list=cl) + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0076_orderfee_squashed_0082_invoiceaddress_internal_reference'), + ] + + operations = [ + migrations.AlterField( + model_name='event', + name='slug', + field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be unique among your events. We recommend some kind of abbreviation or a date with less than 10 characters that can be easily remembered, but you can also choose to use a random value. This will be used in URLs, order codes, invoice numbers, and bank transfer references.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'), + ), + migrations.AlterField( + model_name='eventmetaproperty', + name='name', + field=models.CharField(db_index=True, help_text='Can not contain spaces or special characters except underscores', max_length=50, validators=[django.core.validators.RegexValidator(message='The property name may only contain letters, numbers and underscores.', regex='^[a-zA-Z0-9_]+$')], verbose_name='Name'), + ), + migrations.AlterField( + model_name='organizer', + name='slug', + field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'), + ), + migrations.CreateModel( + name='CheckinList', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('name', models.CharField(max_length=190)), + ('all_products', models.BooleanField(default=True, verbose_name='All products (including newly created ones)')), + ], + ), + migrations.AddField( + model_name='checkinlist', + name='event', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkin_lists', to='pretixbase.Event'), + ), + migrations.AddField( + model_name='checkinlist', + name='subevent', + field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, null=True, blank=True, related_name='checkin_lists', to='pretixbase.SubEvent'), + ), + migrations.AddField( + model_name='checkinlist', + name='limit_products', + field=models.ManyToManyField(blank=True, to='pretixbase.Item', verbose_name='Limit to products'), + ), + migrations.AddField( + model_name='checkin', + name='list', + field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='checkins', to='pretixbase.CheckinList'), + ), + migrations.AlterField( + model_name='checkin', + name='list', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='checkins', to='pretixbase.CheckinList'), + ), + migrations.AlterField( + model_name='checkinlist', + name='subevent', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, to='pretixbase.SubEvent', verbose_name='Date'), + ), + migrations.RunPython( + create_checkin_lists, + migrations.RunPython.noop + ), + migrations.AlterField( + model_name='checkin', + name='list', + field=models.ForeignKey(null=False, on_delete=django.db.models.deletion.PROTECT, related_name='checkins', to='pretixbase.CheckinList'), + ), + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 6ab3cfe36f..ec991ab4d4 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -1,7 +1,7 @@ from ..settings import GlobalSettingsObject_SettingsStore from .auth import U2FDevice, User from .base import CachedFile, LoggedModel, cachedfile_name -from .checkin import Checkin +from .checkin import Checkin, CheckinList from .event import ( Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token, diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 37808779c2..4e4018dbc5 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -1,5 +1,76 @@ from django.db import models +from django.db.models import Case, Count, F, OuterRef, Q, Subquery, When +from django.db.models.functions import Coalesce from django.utils.timezone import now +from django.utils.translation import pgettext_lazy, ugettext_lazy as _ + +from pretix.base.models import LoggedModel + + +class CheckinList(LoggedModel): + event = models.ForeignKey('Event', related_name='checkin_lists') + name = models.CharField(max_length=190) + all_products = models.BooleanField(default=True, verbose_name=_("All products (including newly created ones)")) + 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')) + + @staticmethod + def annotate_with_numbers(qs, event): + from . import Order, OrderPosition + cqs = Checkin.objects.filter( + position__order__event=event, + position__order__status=Order.STATUS_PAID, + 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') + pqs_all = OrderPosition.objects.filter( + order__event=event, + order__status=Order.STATUS_PAID, + ).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') + pqs_limited = OrderPosition.objects.filter( + order__event=event, + order__status=Order.STATUS_PAID, + item__in=OuterRef('limit_products') + ).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') + + 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) + ).annotate( + percent=Case( + When(position_count__gt=0, then=F('checkin_count') * 100 / F('position_count')), + default=0, + output_field=models.IntegerField() + ) + ) + + def __str__(self): + return self.name class Checkin(models.Model): @@ -9,3 +80,11 @@ class Checkin(models.Model): position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins') datetime = models.DateTimeField(default=now) nonce = models.CharField(max_length=190, null=True, blank=True) + list = models.ForeignKey( + 'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT, + ) + + def __repr__(self): + return "-
- +{% blocktrans trimmed %} - No check-in record was found. + No attendee record was found. {% endblocktrans %}
+ {% blocktrans trimmed %} + You can create check-in lists that you can use e.g. at the entrance of your event to track who is coming + and if they actually bought a ticket. You can do this process by printing out the list on paper, using this + web interface or by using one of our mobile or desktop apps to automatically scan tickets. + {% endblocktrans %} +
++ {% blocktrans trimmed %} + You can create multiple check-in lists to separate multiple parts of your event, for example if you have + separate entries for multiple ticket types. Different check-in lists are completely independent: If a ticket + shows up on two lists, it is valid once on every list. This might be useful if you run a festival with + festival passes that allow access to every or multiple performances as well as tickets only valid for single + performances. + {% endblocktrans %} +
+ {% if request.event.has_subevents %} + + {% endif %} + {% if checkinlists|length == 0 %} ++ {% if request.GET.subevent %} + {% trans "Your search did not match any check-in lists." %} + {% else %} + {% blocktrans trimmed %} + You haven't created any check-in lists yet. + {% endblocktrans %} + {% endif %} +
+ + {% if "can_change_event_settings" in request.eventpermset %} + + {% trans "Create a new check-in list" %} + {% endif %} ++ {% trans "Create a new check-in list" %} + +
+ {% endif %} +| {% trans "Check-in lists" %} | +{% trans "Checked in" %} | + {% if request.event.has_subevents %} +{% trans "Date" context "subevent" %} | + {% endif %} +{% trans "Products" %} | ++ |
|---|---|---|---|---|
| + {{ cl.name }} + | +
+
+
+
+
+
+
+ {{ cl.checkin_count|default_if_none:"0" }} / {{ cl.position_count|default_if_none:"0" }}
+
+ |
+ {% if request.event.has_subevents %}
+ {{ cl.subevent.name }} – {{ cl.subevent.get_date_range_display }} | + {% endif %} +
+ {% if cl.all_products %}
+ {% trans "All" %}
+ {% else %}
+
|
+ + + {% if "can_change_event_settings" in request.eventpermset %} + + + {% endif %} + | +