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 "".format( + self.position, self.list, self.datetime + ) diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index d50b4a305a..32859c5212 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -410,6 +410,14 @@ class Event(EventMixin, LoggedModel): o.question = q o.save() + for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'): + items = list(cl.limit_products.all()) + cl.pk = None + cl.event = self + cl.save() + for i in items: + cl.limit_products.add(item_map[i.pk]) + for s in other.settings._objects.all(): s.object = self s.pk = None diff --git a/src/pretix/base/services/export.py b/src/pretix/base/services/export.py index 2b70624ca1..82960a45d4 100644 --- a/src/pretix/base/services/export.py +++ b/src/pretix/base/services/export.py @@ -1,6 +1,7 @@ from typing import Any, Dict from django.core.files.base import ContentFile +from django.utils.timezone import override from pretix.base.i18n import language from pretix.base.models import CachedFile, Event, cachedfile_name @@ -13,7 +14,7 @@ from pretix.celery_app import app def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: event = Event.objects.get(id=event) file = CachedFile.objects.get(id=fileid) - with language(event.settings.locale): + with language(event.settings.locale), override(event.settings.timezone): responses = register_data_exporters.send(event) for receiver, response in responses: ex = response(event) diff --git a/src/pretix/control/forms/checkin.py b/src/pretix/control/forms/checkin.py new file mode 100644 index 0000000000..75ae351254 --- /dev/null +++ b/src/pretix/control/forms/checkin.py @@ -0,0 +1,31 @@ +from django import forms + +from pretix.base.models.checkin import CheckinList + + +class CheckinListForm(forms.ModelForm): + def __init__(self, **kwargs): + self.event = kwargs.pop('event') + kwargs.pop('locales', None) + super().__init__(**kwargs) + self.fields['limit_products'].queryset = self.event.items.all() + if self.event.has_subevents: + self.fields['subevent'].queryset = self.event.subevents.all() + self.fields['subevent'].required = True + else: + del self.fields['subevent'] + + class Meta: + model = CheckinList + localized_fields = '__all__' + fields = [ + 'name', + 'all_products', + 'limit_products', + 'subevent' + ] + widgets = { + 'limit_products': forms.CheckboxSelectMultiple(attrs={ + 'data-inverse-dependency': '<[name$=all_products]' + }), + } diff --git a/src/pretix/control/forms/filter.py b/src/pretix/control/forms/filter.py index f0cbaa0ea9..cd2ac866e5 100644 --- a/src/pretix/control/forms/filter.py +++ b/src/pretix/control/forms/filter.py @@ -1,14 +1,14 @@ from django import forms from django.apps import apps -from django.db.models import Exists, OuterRef, Q -from django.db.models.functions import Concat +from django.db.models import Exists, F, OuterRef, Q +from django.db.models.functions import Coalesce, Concat from django.utils.timezone import now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from pretix.base.models import Event, Invoice, Item, Order, Organizer, SubEvent from pretix.base.signals import register_payment_providers from pretix.control.utils.i18n import i18ncomp -from pretix.helpers.database import rolledback_transaction +from pretix.helpers.database import FixedOrderBy, rolledback_transaction PAYMENT_PROVIDERS = [] @@ -57,7 +57,10 @@ class FilterForm(forms.Form): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['ordering'] = forms.ChoiceField( - choices=sum([[(a, b), ('-' + a, '-' + b)] for a, b in self.orders.items()], []), + choices=sum([ + [(a, a), ('-' + a, '-' + a)] + for a in self.orders.keys() + ], []), required=False ) @@ -136,7 +139,7 @@ class OrderFilterForm(FilterForm): qs = qs.filter(status=s) if fdata.get('ordering'): - qs = qs.order_by(dict(self.fields['ordering'].choices)[fdata.get('ordering')]) + qs = qs.order_by(self.orders[fdata.get('ordering')]) if fdata.get('provider'): qs = qs.filter(payment_provider=fdata.get('provider')) @@ -273,7 +276,7 @@ class SubEventFilterForm(FilterForm): ) if fdata.get('ordering'): - qs = qs.order_by(dict(self.fields['ordering'].choices)[fdata.get('ordering')]) + qs = qs.order_by(self.orders[fdata.get('ordering')]) return qs @@ -387,6 +390,94 @@ class EventFilterForm(FilterForm): ) if fdata.get('ordering'): - qs = qs.order_by(dict(self.fields['ordering'].choices)[fdata.get('ordering')]) + qs = qs.order_by(self.orders[fdata.get('ordering')]) + + return qs + + +class CheckInFilterForm(FilterForm): + orders = { + 'code': ('order__code', 'item__name'), + '-code': ('-order__code', '-item__name'), + 'email': ('order__email', 'item__name'), + '-email': ('-order__email', '-item__name'), + 'status': (FixedOrderBy(F('last_checked_in'), nulls_first=True, descending=True), 'order__code'), + '-status': (FixedOrderBy(F('last_checked_in'), nulls_last=True), '-order__code'), + 'timestamp': (FixedOrderBy(F('last_checked_in'), nulls_first=True), 'order__code'), + '-timestamp': (FixedOrderBy(F('last_checked_in'), nulls_last=True, descending=True), '-order__code'), + 'item': ('item__name', 'variation__value', 'order__code'), + '-item': ('-item__name', '-variation__value', '-order__code'), + 'name': {'_order': F('display_name').asc(nulls_first=True), + 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}, + '-name': {'_order': F('display_name').desc(nulls_last=True), + 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}, + } + + user = forms.CharField( + label=_('Search attendee…'), + widget=forms.TextInput(attrs={ + 'placeholder': _('Search attendee…'), + 'autofocus': 'autofocus' + }), + required=False + ) + status = forms.ChoiceField( + label=_('Check-in status'), + choices=( + ('', _('All attendees')), + ('1', _('Checked in')), + ('0', _('Not checked in')), + ), + required=False, + ) + item = forms.ModelChoiceField( + label=_('Products'), + queryset=Item.objects.none(), + required=False, + empty_label=_('All products') + ) + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event') + self.list = kwargs.pop('list') + super().__init__(*args, **kwargs) + if self.list.all_products: + self.fields['item'].queryset = self.event.items.all() + else: + self.fields['item'].queryset = self.list.limit_products.all() + + def filter_qs(self, qs): + fdata = self.cleaned_data + + if fdata.get('user'): + u = fdata.get('user') + qs = qs.filter( + Q(order__email__icontains=u) + | Q(attendee_name__icontains=u) + | Q(attendee_email__icontains=u) + | Q(order__invoice_address__name__icontains=u) + | Q(order__invoice_address__company__icontains=u) + ) + + if fdata.get('status'): + s = fdata.get('status') + if s == '1': + qs = qs.filter(last_checked_in__isnull=False) + elif s == '0': + qs = qs.filter(last_checked_in__isnull=True) + + if fdata.get('ordering'): + ob = self.orders[fdata.get('ordering')] + if isinstance(ob, dict): + ob = dict(ob) + o = ob.pop('_order') + qs = qs.annotate(**ob).order_by(o) + elif isinstance(ob, (list, tuple)): + qs = qs.order_by(*ob) + else: + qs = qs.order_by(ob) + + if fdata.get('item'): + qs = qs.filter(item=fdata.get('item')) return qs diff --git a/src/pretix/control/forms/subevents.py b/src/pretix/control/forms/subevents.py index 1bdc8ada4a..e857d8b5fe 100644 --- a/src/pretix/control/forms/subevents.py +++ b/src/pretix/control/forms/subevents.py @@ -123,3 +123,30 @@ class SubEventMetaValueForm(forms.ModelForm): widgets = { 'value': forms.TextInput } + + +class CheckinListFormSet(I18nInlineFormSet): + + def __init__(self, *args, **kwargs): + self.event = kwargs.pop('event', None) + self.locales = self.event.settings.get('locales') + super().__init__(*args, **kwargs) + + @cached_property + def items(self): + return self.event.items.prefetch_related('variations').all() + + def _construct_form(self, i, **kwargs): + kwargs['event'] = self.event + return super()._construct_form(i, **kwargs) + + @property + def empty_form(self): + form = self.form( + auto_id=self.auto_id, + prefix=self.add_prefix('__prefix__'), + empty_permitted=True, + event=self.event, + ) + self.add_fields(form, None) + return form diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 617233432d..9382692567 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -9,7 +9,9 @@ from django.utils.formats import date_format from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from i18nfield.strings import LazyI18nString -from pretix.base.models import Event, ItemVariation, LogEntry, OrderPosition +from pretix.base.models import ( + CheckinList, Event, ItemVariation, LogEntry, OrderPosition, +) from pretix.base.signals import logentry_display OVERVIEW_BLACKLIST = [ @@ -167,6 +169,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): 'pretix.event.taxrule.added': _('The tax rule has been added.'), 'pretix.event.taxrule.deleted': _('The tax rule has been deleted.'), 'pretix.event.taxrule.changed': _('The tax rule has been changed.'), + 'pretix.event.checkinlist.added': _('The check-in list has been added.'), + 'pretix.event.checkinlist.deleted': _('The check-in list has been deleted.'), + 'pretix.event.checkinlist.changed': _('The check-in list has been changed.'), 'pretix.event.settings': _('The event settings have been changed.'), 'pretix.event.tickets.settings': _('The ticket download settings have been changed.'), 'pretix.event.plugins.enabled': _('A plugin has been enabled.'), @@ -222,15 +227,24 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs): dt = dateutil.parser.parse(data.get('datetime')) 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'): - return _('Position #{posid} has been checked in manually at {datetime}.').format( + return _('Position #{posid} has been checked in manually at {datetime} on list "{list}".').format( posid=data.get('positionid'), - datetime=dt_formatted + datetime=dt_formatted, + list=checkin_list, ) - return _('Position #{posid} has been checked in again at {datetime}.').format( + return _('Position #{posid} has been checked in again at {datetime} on list "{list}".').format( posid=data.get('positionid'), - datetime=dt_formatted + datetime=dt_formatted, + list=checkin_list ) if logentry.action_type == 'pretix.team.member.added': diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 07a0879512..60b8def126 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -32,6 +32,7 @@ + diff --git a/src/pretix/control/templates/pretixcontrol/checkin/index.html b/src/pretix/control/templates/pretixcontrol/checkin/index.html index b8f07ca2cd..f61e6b4cef 100644 --- a/src/pretix/control/templates/pretixcontrol/checkin/index.html +++ b/src/pretix/control/templates/pretixcontrol/checkin/index.html @@ -2,65 +2,70 @@ {% load i18n %} {% load eventurl %} {% load urlreplace %} -{% block title %}{% trans "Check-ins" %}{% endblock %} +{% load bootstrap3 %} +{% block title %} + {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} +{% endblock %} {% block content %} -

{% trans "Check-ins" %}

-

-

- - - {% if request.event.has_subevents %} - - {% endif %} - - -
-

+

+ {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} + {% if 'can_change_event_settings' in request.eventpermset %} + + + {% trans "Edit list" %} + + {% endif %} + + + {% trans "PDF" %} + + + + {% trans "CSV" %} + +

+
+
+ {% bootstrap_field filter_form.user layout='inline' %} +
+
+ {% bootstrap_field filter_form.status layout='inline' %} +
+
+ {% bootstrap_field filter_form.item layout='inline' %} +
+
+ +
+
{% if entries|length == 0 %}

{% blocktrans trimmed %} - No check-in record was found. + No attendee record was found. {% endblocktrans %}

{% else %} - {% include "pretixcontrol/pagination.html" %}
{% csrf_token %}
- - {% if request.event.has_subevents %} - - {% endif %} {% for e in entries %} - {% with e.checkins.first as checkin %} - + - {% if request.event.has_subevents %} - - {% endif %} - {% endwith %} {% endfor %}
+ {% trans "Order code" %} {% trans "Item" %} {% trans "Date" context "subevent" %} - {% trans "Email" %} {% trans "Name" %} @@ -73,20 +78,16 @@
- - + {% if "can_change_orders" in request.eventpermset %} + + {% endif %} + {{ e.order.code }} {{ e.item.name }}{% if e.variation %} – {{ e.variation }}{% endif %}{{ e.subevent.name }} – {{ e.subevent.get_date_range_display }}{{ e.order.email }} {% if e.addon_to %} @@ -96,26 +97,27 @@ {% endif %} - {% if not checkin %} + {% if not e.last_checked_in %} {% trans "Not checked in" %} {% else %} {% trans "Checked in" %} {% endif %} - {% if checkin %} - {{ checkin.datetime|date:"SHORT_DATETIME_FORMAT" }} + {% if e.last_checked_in %} + {{ e.last_checked_in_aware|date:"SHORT_DATETIME_FORMAT" }} {% endif %}
- + {% if "can_change_orders" in request.eventpermset %} + + {% endif %}
{% include "pretixcontrol/pagination.html" %} {% endif %} diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_delete.html b/src/pretix/control/templates/pretixcontrol/checkin/list_delete.html new file mode 100644 index 0000000000..a45d40d898 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/checkin/list_delete.html @@ -0,0 +1,25 @@ +{% extends "pretixcontrol/items/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Delete check-in list" %}{% endblock %} +{% block inside %} +

{% trans "Delete check-in list" %}

+
+ {% csrf_token %} +

{% blocktrans with name=checkinlist.name %}Are you sure you want to delete the check-in list {{ name }}?{% endblocktrans %}

+ {% if checkinlist.checkins.exists > 0 %} +

{% blocktrans with num=checkinlist.checkins.count %} + This will delete the information of {{ num }} check-ins as well. + {% endblocktrans %}

+ {% endif %} +
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html new file mode 100644 index 0000000000..a1efb57117 --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/checkin/list_edit.html @@ -0,0 +1,41 @@ +{% extends "pretixcontrol/items/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %} + {% if checkinlist %} + {% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %} + {% else %} + {% trans "Check-in list" %} + {% endif %} +{% endblock %} +{% block inside %} + {% if checkinlist %} +

{% blocktrans with name=checkinlist.name %}Check-in list: {{ name }}{% endblocktrans %}

+ {% else %} +

{% trans "Check-in list" %}

+ {% endif %} +
+ {% csrf_token %} + {% bootstrap_form_errors form %} +
+ {% trans "General information" %} + {% bootstrap_field form.name layout="control" %} + {% if form.subevent %} + {% bootstrap_field form.subevent layout="control" %} + {% endif %} + {% trans "Products" %} +

+ {% blocktrans trimmed %} + Please select the products or product variations that should be part of this check-in list. + {% endblocktrans %} +

+ {% bootstrap_field form.all_products layout="control" %} + {% bootstrap_field form.limit_products layout="control" %} +
+
+ +
+
+{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/checkin/lists.html b/src/pretix/control/templates/pretixcontrol/checkin/lists.html new file mode 100644 index 0000000000..291f9d561b --- /dev/null +++ b/src/pretix/control/templates/pretixcontrol/checkin/lists.html @@ -0,0 +1,125 @@ +{% extends "pretixcontrol/items/base.html" %} +{% load i18n %} +{% block title %}{% trans "Check-in lists" %}{% endblock %} +{% block inside %} +

{% trans "Check-in lists" %}

+

+ {% 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 %} +
+

+ {% if request.event.has_subevents %} + + {% endif %} + +

+
+ {% 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 %} +
+ {% else %} + {% if "can_change_event_settings" in request.eventpermset %} +

+ {% trans "Create a new check-in list" %} + +

+ {% endif %} +
+ + + + + + {% if request.event.has_subevents %} + + {% endif %} + + + + + + {% for cl in checkinlists %} + + + + {% if request.event.has_subevents %} + + {% endif %} + + + + {% endfor %} + +
{% trans "Check-in lists" %}{% trans "Checked in" %}{% trans "Date" context "subevent" %}{% trans "Products" %}
+ {{ cl.name }} + +
+
+
+
+
+
+ {{ cl.checkin_count|default_if_none:"0" }} / {{ cl.position_count|default_if_none:"0" }} +
+
+
{{ cl.subevent.name }} – {{ cl.subevent.get_date_range_display }} + {% if cl.all_products %} + {% trans "All" %} + {% else %} +
    + {% for item in cl.limit_products.all %} +
  • + {{ item.name }} +
  • + {% endfor %} +
+ {% endif %} +
+ + {% if "can_change_event_settings" in request.eventpermset %} + + + {% endif %} +
+
+ {% endif %} + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/control/templates/pretixcontrol/event/base.html b/src/pretix/control/templates/pretixcontrol/event/base.html index 1c5de3fa2e..c778f8bcd0 100644 --- a/src/pretix/control/templates/pretixcontrol/event/base.html +++ b/src/pretix/control/templates/pretixcontrol/event/base.html @@ -101,12 +101,6 @@ {% trans "Waiting list" %} -
  • - - {% trans "Check-ins" %} - -
  • {% endif %} @@ -119,6 +113,15 @@ {% endif %} + {% if 'can_view_orders' in request.eventpermset %} +
  • + + + {% trans "Check-in lists" %} + +
  • + {% endif %} {% for nav in nav_event %}
  • + {% for c in line.checkins.all %} + + {% endfor %} {% endif %} {% if line.voucher %}
    {% trans "Voucher code used:" %} diff --git a/src/pretix/control/templates/pretixcontrol/orders/export.html b/src/pretix/control/templates/pretixcontrol/orders/export.html index 00f8acf4b5..00d957d43d 100644 --- a/src/pretix/control/templates/pretixcontrol/orders/export.html +++ b/src/pretix/control/templates/pretixcontrol/orders/export.html @@ -4,23 +4,36 @@ {% load order_overview %} {% block title %}{% trans "Data export" %}{% endblock %} {% block content %} -

    {% trans "Data export" %}

    +

    + {% trans "Data export" %} + {% if "identifier" in request.GET %} + {% trans "Show all" %} + {% endif %} +

    {% for e in exporters %}
    -

    {{ e.verbose_name }}

    +

    + +

    -
    -
    - {% csrf_token %} - - {% bootstrap_form e.form layout='horizontal' %} - -
    +
    +
    +
    + {% csrf_token %} + + {% bootstrap_form e.form layout='horizontal' %} + +
    +
    {% endfor %} diff --git a/src/pretix/control/templates/pretixcontrol/subevents/detail.html b/src/pretix/control/templates/pretixcontrol/subevents/detail.html index 2ca28691b2..6119482f91 100644 --- a/src/pretix/control/templates/pretixcontrol/subevents/detail.html +++ b/src/pretix/control/templates/pretixcontrol/subevents/detail.html @@ -123,6 +123,72 @@ {% bootstrap_field f.price layout="control" %} {% endfor %} +
    + {% trans "Check-in lists" %} +
    + {{ cl_formset.management_form }} + {% bootstrap_formset_errors cl_formset %} +
    + {% for form in cl_formset %} +
    +
    + {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} +
    +
    +

    +
    +
    + {% bootstrap_field form.name layout='inline' form_group_class="" %} +
    +
    + +
    +
    +

    +
    +
    + {% bootstrap_form_errors form %} + {% bootstrap_field form.all_products layout="control" %} + {% bootstrap_field form.limit_products layout="control" %} +
    +
    + {% endfor %} +
    + +

    + +

    +
    {% if subevent.pk %}
    diff --git a/src/pretix/control/urls.py b/src/pretix/control/urls.py index 56e9bf07dd..cf1de1e017 100644 --- a/src/pretix/control/urls.py +++ b/src/pretix/control/urls.py @@ -164,7 +164,14 @@ urlpatterns = [ url(r'^orders/$', orders.OrderList.as_view(), name='event.orders'), url(r'^waitinglist/$', waitinglist.WaitingListView.as_view(), name='event.orders.waitinglist'), url(r'^waitinglist/auto_assign$', waitinglist.AutoAssign.as_view(), name='event.orders.waitinglist.auto'), - url(r'^waitinglist/(?P\d+)/delete$', waitinglist.EntryDelete.as_view(), name='event.orders.waitinglist.delete'), - url(r'^checkins/$', checkin.CheckInView.as_view(), name='event.orders.checkins'), + url(r'^waitinglist/(?P\d+)/delete$', waitinglist.EntryDelete.as_view(), + name='event.orders.waitinglist.delete'), + url(r'^checkinlists/$', checkin.CheckinListList.as_view(), name='event.orders.checkinlists'), + url(r'^checkinlists/add$', checkin.CheckinListCreate.as_view(), name='event.orders.checkinlists.add'), + url(r'^checkinlists/(?P\d+)/$', checkin.CheckInListShow.as_view(), name='event.orders.checkinlists.show'), + url(r'^checkinlists/(?P\d+)/change$', checkin.CheckinListUpdate.as_view(), + name='event.orders.checkinlists.edit'), + url(r'^checkinlists/(?P\d+)/delete$', checkin.CheckinListDelete.as_view(), + name='event.orders.checkinlists.delete'), ])), ] diff --git a/src/pretix/control/views/checkin.py b/src/pretix/control/views/checkin.py index 60fd65befa..3f44d88632 100644 --- a/src/pretix/control/views/checkin.py +++ b/src/pretix/control/views/checkin.py @@ -1,122 +1,221 @@ +import dateutil.parser from django.contrib import messages from django.core.urlresolvers import reverse -from django.db.models import F, Prefetch, Q -from django.db.models.functions import Coalesce -from django.shortcuts import redirect -from django.utils.timezone import now +from django.db import transaction +from django.db.models import Max, OuterRef, Subquery +from django.http import Http404, HttpResponseRedirect +from django.shortcuts import get_object_or_404, redirect +from django.utils.functional import cached_property +from django.utils.timezone import make_aware, now from django.utils.translation import ugettext_lazy as _ -from django.views.generic import ListView +from django.views.generic import DeleteView, ListView +from pytz import UTC -from pretix.base.models import Checkin, Item, Order, OrderPosition +from pretix.base.models import Checkin, Order, OrderPosition +from pretix.base.models.checkin import CheckinList +from pretix.control.forms.checkin import CheckinListForm +from pretix.control.forms.filter import CheckInFilterForm from pretix.control.permissions import EventPermissionRequiredMixin +from pretix.control.views import CreateView, UpdateView -class CheckInView(EventPermissionRequiredMixin, ListView): +class CheckInListShow(EventPermissionRequiredMixin, ListView): model = Checkin context_object_name = 'entries' paginate_by = 30 template_name = 'pretixcontrol/checkin/index.html' permission = 'can_view_orders' - def get_queryset(self): + def get_queryset(self, filter=True): + cqs = Checkin.objects.filter( + position_id=OuterRef('pk'), + list_id=self.list.pk + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') - qs = OrderPosition.objects.filter(order__event=self.request.event, order__status='p') + qs = OrderPosition.objects.filter( + order__event=self.request.event, + order__status=Order.STATUS_PAID, + subevent=self.list.subevent + ).annotate( + last_checked_in=Subquery(cqs) + ).select_related('item', 'variation', 'order', 'addon_to') - # if this setting is False, we check only items for admission - if not self.request.event.settings.ticket_download_nonadm: - qs = qs.filter(item__admission=True) + if not self.list.all_products: + qs = qs.filter(item__in=self.list.limit_products.values_list('id', flat=True)) - if self.request.GET.get("status", "") != "": - p = self.request.GET.get("status", "") - if p == '1': - # records with check-in record - qs = qs.filter(checkins__isnull=False) - elif p == '0': - qs = qs.filter(checkins__isnull=True) + if filter and self.filter_form.is_valid(): + qs = self.filter_form.filter_qs(qs) - if self.request.GET.get("user", "") != "": - u = self.request.GET.get("user", "") - qs = qs.filter( - Q(order__email__icontains=u) | Q(attendee_name__icontains=u) | Q(attendee_email__icontains=u) - ) + return qs - if self.request.GET.get("item", "") != "": - u = self.request.GET.get("item", "") - qs = qs.filter(item_id=u) + @cached_property + def filter_form(self): + return CheckInFilterForm( + data=self.request.GET, + event=self.request.event, + list=self.list + ) - if self.request.GET.get("subevent", "") != "": - s = self.request.GET.get("subevent", "") - qs = qs.filter(subevent_id=s) - - qs = qs.prefetch_related( - Prefetch('checkins', queryset=Checkin.objects.filter(position__order__event=self.request.event)) - ).select_related('order', 'item', 'addon_to') - - if self.request.GET.get("ordering", "") != "": - p = self.request.GET.get("ordering", "") - keys_allowed = self.get_ordering_keys_mappings() - if p in keys_allowed: - mapped_field = keys_allowed[p] - if isinstance(mapped_field, dict): - order = mapped_field.pop('_order') - qs = qs.annotate(**mapped_field).order_by(order) - elif isinstance(mapped_field, (list, tuple)): - qs = qs.order_by(*mapped_field) - else: - qs = qs.order_by(mapped_field) - - return qs.distinct() + def dispatch(self, request, *args, **kwargs): + self.list = get_object_or_404(self.request.event.checkin_lists.all(), pk=kwargs.get("list")) + return super().dispatch(request, *args, **kwargs) def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) - ctx['items'] = Item.objects.filter(event=self.request.event) - ctx['filtered'] = ("status" in self.request.GET or "user" in self.request.GET or "item" in self.request.GET - or "subevent" in self.request.GET) + ctx['checkinlist'] = self.list + ctx['filter_form'] = self.filter_form + for e in ctx['entries']: + if e.last_checked_in: + if isinstance(e.last_checked_in, str): + # Apparently only happens on SQLite + e.last_checked_in_aware = make_aware(dateutil.parser.parse(e.last_checked_in), UTC) + else: + e.last_checked_in_aware = e.last_checked_in return ctx def post(self, request, *args, **kwargs): - positions = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').filter( - order__event=self.request.event, + if "can_change_orders" not in request.eventpermset: + messages.error(request, _('You do not have permission to perform this action.')) + return redirect(reverse('control:event.orders.checkins', kwargs={ + 'event': self.request.event.slug, + 'organizer': self.request.event.organizer.slug + }) + '?' + request.GET.urlencode()) + + positions = self.get_queryset(filter=False).filter( pk__in=request.POST.getlist('checkin') ) for op in positions: created = False if op.order.status == Order.STATUS_PAID: - ci, created = Checkin.objects.get_or_create(position=op, defaults={ + ci, created = Checkin.objects.get_or_create(position=op, list=self.list, defaults={ 'datetime': now(), }) op.order.log_action('pretix.control.views.checkin', data={ 'position': op.id, 'positionid': op.positionid, 'first': created, - 'datetime': now() + 'datetime': now(), + 'list': self.list.pk }, user=request.user) messages.success(request, _('The selected tickets have been marked as checked in.')) - return redirect(reverse('control:event.orders.checkins', kwargs={ + return redirect(reverse('control:event.orders.checkinlists.show', kwargs={ 'event': self.request.event.slug, - 'organizer': self.request.event.organizer.slug + 'organizer': self.request.event.organizer.slug, + 'list': self.list.pk }) + '?' + request.GET.urlencode()) - @staticmethod - def get_ordering_keys_mappings(): - return { - 'code': 'order__code', - '-code': '-order__code', - 'email': 'order__email', - '-email': '-order__email', - # Set nulls_first to be consistent over databases - 'status': F('checkins__id').asc(nulls_first=True), - '-status': F('checkins__id').desc(nulls_last=True), - 'timestamp': F('checkins__datetime').asc(nulls_first=True), - '-timestamp': F('checkins__datetime').desc(nulls_last=True), - 'item': ('item__name', 'variation__value'), - '-item': ('-item__name', 'variation__value'), - 'subevent': ('subevent__date_from', 'subevent__name'), - '-subevent': ('-subevent__date_from', '-subevent__name'), - 'name': {'_order': F('display_name').asc(nulls_first=True), - 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}, - '-name': {'_order': F('display_name').desc(nulls_last=True), - 'display_name': Coalesce('attendee_name', 'addon_to__attendee_name')}, - } + +class CheckinListList(EventPermissionRequiredMixin, ListView): + model = CheckinList + context_object_name = 'checkinlists' + paginate_by = 30 + permission = 'can_view_orders' + template_name = 'pretixcontrol/checkin/lists.html' + + def get_queryset(self): + qs = self.request.event.checkin_lists.prefetch_related("limit_products") + qs = CheckinList.annotate_with_numbers(qs, self.request.event) + + if self.request.GET.get("subevent", "") != "": + s = self.request.GET.get("subevent", "") + qs = qs.filter(subevent_id=s) + return qs + + +class CheckinListCreate(EventPermissionRequiredMixin, CreateView): + model = CheckinList + form_class = CheckinListForm + template_name = 'pretixcontrol/checkin/list_edit.html' + permission = 'can_change_event_settings' + context_object_name = 'checkinlist' + + def get_success_url(self) -> str: + return reverse('control:event.orders.checkinlists', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) + + @transaction.atomic + def form_valid(self, form): + form.instance.event = self.request.event + messages.success(self.request, _('The new check-in list has been created.')) + ret = super().form_valid(form) + form.instance.log_action('pretix.event.checkinlist.added', user=self.request.user, + data=dict(form.cleaned_data)) + return ret + + def form_invalid(self, form): + messages.error(self.request, _('We could not save your changes. See below for details.')) + return super().form_invalid(form) + + +class CheckinListUpdate(EventPermissionRequiredMixin, UpdateView): + model = CheckinList + form_class = CheckinListForm + template_name = 'pretixcontrol/checkin/list_edit.html' + permission = 'can_change_event_settings' + context_object_name = 'checkinlist' + + def get_object(self, queryset=None) -> CheckinList: + try: + return self.request.event.checkin_lists.get( + id=self.kwargs['list'] + ) + except CheckinList.DoesNotExist: + raise Http404(_("The requested list does not exist.")) + + @transaction.atomic + def form_valid(self, form): + messages.success(self.request, _('Your changes have been saved.')) + if form.has_changed(): + self.object.log_action( + 'pretix.event.checkinlist.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + return super().form_valid(form) + + def get_success_url(self) -> str: + return reverse('control:event.orders.checkinlists.show', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + 'list': self.object.pk + }) + + def form_invalid(self, form): + messages.error(self.request, _('We could not save your changes. See below for details.')) + return super().form_invalid(form) + + +class CheckinListDelete(EventPermissionRequiredMixin, DeleteView): + model = CheckinList + template_name = 'pretixcontrol/checkin/list_delete.html' + permission = 'can_change_event_settings' + context_object_name = 'checkinlist' + + def get_object(self, queryset=None) -> CheckinList: + try: + return self.request.event.checkin_lists.get( + id=self.kwargs['list'] + ) + except CheckinList.DoesNotExist: + raise Http404(_("The requested list does not exist.")) + + @transaction.atomic + def delete(self, request, *args, **kwargs): + self.object = self.get_object() + success_url = self.get_success_url() + self.object.log_action(action='pretix.event.orders.deleted', user=request.user) + self.object.delete() + messages.success(self.request, _('The selected list has been deleted.')) + return HttpResponseRedirect(success_url) + + def get_success_url(self) -> str: + return reverse('control:event.orders.checkinlists', kwargs={ + 'organizer': self.request.event.organizer.slug, + 'event': self.request.event.slug, + }) diff --git a/src/pretix/control/views/dashboards.py b/src/pretix/control/views/dashboards.py index b5c297a695..e8d4e30d56 100644 --- a/src/pretix/control/views/dashboards.py +++ b/src/pretix/control/views/dashboards.py @@ -20,6 +20,7 @@ from pretix.base.models import ( Event, Item, Order, OrderPosition, RequiredAction, SubEvent, Voucher, WaitingListEntry, ) +from pretix.base.models.checkin import CheckinList from pretix.control.forms.event import CommentForm from pretix.control.signals import ( event_dashboard_widgets, user_dashboard_widgets, @@ -190,7 +191,7 @@ def shop_state_widget(sender, **kwargs): @receiver(signal=event_dashboard_widgets) -def checkin_widget(sender, **kwargs): +def checkin_widget(sender, subevent=None, **kwargs): size_qs = OrderPosition.objects.filter(order__event=sender, order__status='p') checked_qs = OrderPosition.objects.filter(order__event=sender, order__status='p', checkins__isnull=False) @@ -199,15 +200,22 @@ def checkin_widget(sender, **kwargs): size_qs = size_qs.filter(item__admission=True) checked_qs = checked_qs.filter(item__admission=True) - return [{ - 'content': NUM_WIDGET.format(num='{}/{}'.format(checked_qs.count(), size_qs.count()), text=_('Checked in')), - 'display_size': 'small', - 'priority': 50, - 'url': reverse('control:event.orders.checkins', kwargs={ - 'event': sender.slug, - 'organizer': sender.organizer.slug + widgets = [] + qs = sender.checkin_lists.filter(subevent=subevent) + qs = CheckinList.annotate_with_numbers(qs, sender) + for cl in qs: + widgets.append({ + 'content': NUM_WIDGET.format(num='{}/{}'.format(cl.checkin_count, cl.position_count), + text=_('Checked in – {list}').format(list=escape(cl.name))), + 'display_size': 'small', + 'priority': 50, + 'url': reverse('control:event.orders.checkinlists.show', kwargs={ + 'event': sender.slug, + 'organizer': sender.organizer.slug, + 'list': cl.pk + }) }) - }] + return widgets @receiver(signal=event_dashboard_widgets) diff --git a/src/pretix/control/views/main.py b/src/pretix/control/views/main.py index 02b485e26b..e98d7369cd 100644 --- a/src/pretix/control/views/main.py +++ b/src/pretix/control/views/main.py @@ -15,6 +15,7 @@ from django.views.generic import ListView from formtools.wizard.views import SessionWizardView from i18nfield.strings import LazyI18nString +from pretix.base.i18n import language from pretix.base.models import Event, Organizer, Quota, Team from pretix.control.forms.event import ( EventWizardBasicsForm, EventWizardCopyForm, EventWizardFoundationForm, @@ -143,7 +144,7 @@ class EventWizard(SessionWizardView): basics_data = self.get_cleaned_data_for_step('basics') copy_data = self.get_cleaned_data_for_step('copy') - with transaction.atomic(): + with transaction.atomic(), language(basics_data['locale']): event = form_dict['basics'].instance event.organizer = foundation_data['organizer'] event.plugins = settings.PRETIX_PLUGINS_DEFAULT @@ -165,7 +166,7 @@ class EventWizard(SessionWizardView): t.limit_events.add(event) if event.has_subevents: - event.subevents.create( + se = event.subevents.create( name=event.name, date_from=event.date_from, date_to=event.date_to, @@ -191,6 +192,17 @@ class EventWizard(SessionWizardView): if copy_data and copy_data['copy_from_event']: from_event = copy_data['copy_from_event'] event.copy_data_from(from_event) + elif event.has_subevents: + event.checkin_lists.create( + name=str(se), + all_products=True, + subevent=se + ) + else: + event.checkin_lists.create( + name=_('Default'), + all_products=True + ) event.settings.set('timezone', basics_data['timezone']) event.settings.set('locale', basics_data['locale']) diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 0131e6d1db..553342f533 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -147,7 +147,7 @@ class OrderDetail(OrderView): ).select_related( 'item', 'variation', 'addon_to', 'tax_rule' ).prefetch_related( - 'item__questions', 'answers', 'answers__question', 'checkins' + 'item__questions', 'answers', 'answers__question', 'checkins', 'checkins__list' ).order_by('positionid') positions = [] @@ -906,9 +906,21 @@ class ExportMixin: responses = register_data_exporters.send(self.request.event) for receiver, response in responses: ex = response(self.request.event) + if self.request.GET.get("identifier") and ex.identifier != self.request.GET.get("identifier"): + continue + + # Use form parse cycle to generate useful defaults + test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) + test_form.fields = ex.export_form_fields + test_form.is_valid() + initial = { + k: v for k, v in test_form.cleaned_data.items() if ex.identifier + "-" + k in self.request.GET + } + ex.form = ExporterForm( data=(self.request.POST if self.request.method == 'POST' else None), - prefix=ex.identifier + prefix=ex.identifier, + initial=initial ) ex.form.fields = ex.export_form_fields exporters.append(ex) diff --git a/src/pretix/control/views/subevents.py b/src/pretix/control/views/subevents.py index a4a2265ae2..f3f349e7c3 100644 --- a/src/pretix/control/views/subevents.py +++ b/src/pretix/control/views/subevents.py @@ -11,13 +11,15 @@ from django.utils.functional import cached_property from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.views.generic import CreateView, DeleteView, ListView, UpdateView +from pretix.base.models.checkin import CheckinList from pretix.base.models.event import SubEvent, SubEventMetaValue from pretix.base.models.items import Quota, SubEventItem, SubEventItemVariation +from pretix.control.forms.checkin import CheckinListForm from pretix.control.forms.filter import SubEventFilterForm from pretix.control.forms.item import QuotaForm from pretix.control.forms.subevents import ( - QuotaFormSet, SubEventForm, SubEventItemForm, SubEventItemVariationForm, - SubEventMetaValueForm, + CheckinListFormSet, QuotaFormSet, SubEventForm, SubEventItemForm, + SubEventItemVariationForm, SubEventMetaValueForm, ) from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.views.event import MetaDataEditorMixin @@ -132,6 +134,41 @@ class SubEventEditorMixin(MetaDataEditorMixin): data=(self.request.POST if self.request.method == "POST" else None) ) + @cached_property + def cl_formset(self): + extra = 0 + kwargs = {} + + if self.copy_from: + kwargs['initial'] = [ + { + 'name': cl.name, + 'all_products': cl.all_products, + 'limit_products': cl.limit_products.all(), + } for cl in self.copy_from.checkinlist_set.prefetch_related('limit_products') + ] + extra = len(kwargs['initial']) + elif not self.object: + kwargs['initial'] = [ + { + 'name': '', + 'all_products': True, + } + ] + extra = 1 + + formsetclass = inlineformset_factory( + SubEvent, CheckinList, + form=CheckinListForm, formset=CheckinListFormSet, + can_order=False, can_delete=True, extra=extra, + ) + if self.object: + kwargs['queryset'] = self.object.checkinlist_set.prefetch_related('limit_products') + + return formsetclass(self.request.POST if self.request.method == "POST" else None, + instance=self.object, + event=self.request.event, **kwargs) + @cached_property def formset(self): extra = 0 @@ -161,6 +198,38 @@ class SubEventEditorMixin(MetaDataEditorMixin): instance=self.object, event=self.request.event, **kwargs) + def save_cl_formset(self, obj): + for form in self.cl_formset.initial_forms: + if form in self.cl_formset.deleted_forms: + if not form.instance.pk: + continue + form.instance.log_action(action='pretix.event.checkinlist.deleted', user=self.request.user) + form.instance.delete() + form.instance.pk = None + elif form.has_changed(): + form.instance.subevent = obj + form.instance.event = obj.event + form.save() + change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} + change_data['id'] = form.instance.pk + form.instance.log_action( + 'pretix.event.checkinlist.changed', user=self.request.user, data={ + k: form.cleaned_data.get(k) for k in form.changed_data + } + ) + + for form in self.cl_formset.extra_forms: + if not form.has_changed(): + continue + if self.formset._should_delete_form(form): + continue + form.instance.subevent = obj + form.instance.event = obj.event + form.save() + change_data = {k: form.cleaned_data.get(k) for k in form.changed_data} + change_data['id'] = form.instance.pk + form.instance.log_action(action='pretix.event.checkinlist.added', user=self.request.user, data=change_data) + def save_formset(self, obj): for form in self.formset.initial_forms: if form in self.formset.deleted_forms: @@ -204,6 +273,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): def get_context_data(self, **kwargs): ctx = super().get_context_data(**kwargs) ctx['formset'] = self.formset + ctx['cl_formset'] = self.cl_formset ctx['itemvar_forms'] = self.itemvar_forms ctx['meta_forms'] = self.meta_forms return ctx @@ -259,7 +329,7 @@ class SubEventEditorMixin(MetaDataEditorMixin): def is_valid(self, form): return form.is_valid() and all([f.is_valid() for f in self.itemvar_forms]) and self.formset.is_valid() and ( all([f.is_valid() for f in self.meta_forms]) - ) + ) and self.cl_formset.is_valid() class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateView): @@ -288,6 +358,7 @@ class SubEventUpdate(EventPermissionRequiredMixin, SubEventEditorMixin, UpdateVi @transaction.atomic def form_valid(self, form): self.save_formset(self.object) + self.save_cl_formset(self.object) self.save_meta() for f in self.itemvar_forms: @@ -355,6 +426,7 @@ class SubEventCreate(SubEventEditorMixin, EventPermissionRequiredMixin, CreateVi form.instance.log_action('pretix.subevent.added', data=dict(form.cleaned_data), user=self.request.user) self.save_formset(form.instance) + self.save_cl_formset(form.instance) for f in self.itemvar_forms: f.instance.subevent = form.instance f.save() diff --git a/src/pretix/helpers/database.py b/src/pretix/helpers/database.py index e2d88419f9..e8af8bff2c 100644 --- a/src/pretix/helpers/database.py +++ b/src/pretix/helpers/database.py @@ -1,6 +1,7 @@ import contextlib from django.db import transaction +from django.db.models.expressions import OrderBy class DummyRollbackException(Exception): @@ -34,3 +35,25 @@ def casual_reads(): Kept for backwards compatibility. """ yield + + +class FixedOrderBy(OrderBy): + # Workaround for https://code.djangoproject.com/ticket/28848 + template = '%(expression)s %(ordering)s' + + def as_sql(self, compiler, connection, template=None, **extra_context): + if not template: + if self.nulls_last: + template = '%s NULLS LAST' % self.template + elif self.nulls_first: + template = '%s NULLS FIRST' % self.template + connection.ops.check_expression_support(self) + expression_sql, params = compiler.compile(self.expression) + placeholders = { + 'expression': expression_sql, + 'ordering': 'DESC' if self.descending else 'ASC', + } + placeholders.update(extra_context) + template = template or self.template + params = params * template.count('%(expression)s') + return (template % placeholders).rstrip(), params diff --git a/src/pretix/plugins/checkinlists/__init__.py b/src/pretix/plugins/checkinlists/__init__.py index d0fb5bdbd3..d5daabe732 100644 --- a/src/pretix/plugins/checkinlists/__init__.py +++ b/src/pretix/plugins/checkinlists/__init__.py @@ -13,6 +13,7 @@ class CheckinlistsApp(AppConfig): name = _("Check-in list exporter") author = _("the pretix team") version = version + visible = False description = _("This plugin allows you to generate check-in lists for your conference.") def ready(self): diff --git a/src/pretix/plugins/checkinlists/exporters.py b/src/pretix/plugins/checkinlists/exporters.py index c97b053f04..61502f41de 100644 --- a/src/pretix/plugins/checkinlists/exporters.py +++ b/src/pretix/plugins/checkinlists/exporters.py @@ -3,36 +3,31 @@ from collections import OrderedDict from defusedcsv import csv from django import forms +from django.db.models import Max, OuterRef, Subquery from django.db.models.functions import Coalesce -from django.utils.translation import ( - pgettext, pgettext_lazy, ugettext as _, ugettext_lazy, -) +from django.utils.formats import localize +from django.utils.translation import pgettext, ugettext as _, ugettext_lazy +from reportlab.lib.units import mm +from reportlab.platypus import Flowable, Paragraph, Spacer, Table, TableStyle from pretix.base.exporter import BaseExporter -from pretix.base.models import Order, OrderPosition, Question +from pretix.base.models import Checkin, Order, OrderPosition, Question +from pretix.plugins.reports.exporters import ReportlabExportMixin class BaseCheckinList(BaseExporter): - pass - - -class CSVCheckinList(BaseCheckinList): - name = "overview" - identifier = 'checkinlistcsv' - verbose_name = ugettext_lazy('Check-in list (CSV)') - @property def export_form_fields(self): d = OrderedDict( [ - ('items', - forms.ModelMultipleChoiceField( - queryset=self.event.items.all(), - label=_('Limit to products'), - widget=forms.CheckboxSelectMultiple( - attrs={'class': 'scrolling-multiple-choice'} + ('list', + forms.ModelChoiceField( + queryset=self.event.checkin_lists.all(), + label=_('Check-in list'), + widget=forms.RadioSelect( + attrs={'class': 'scrolling-choice'} ), - initial=self.event.items.filter(admission=True) + initial=self.event.checkin_lists.first() )), ('secrets', forms.BooleanField( @@ -67,26 +62,191 @@ class CSVCheckinList(BaseCheckinList): )), ] ) - if self.event.has_subevents: - d['subevent'] = forms.ModelChoiceField( - self.event.subevents.all(), - label=pgettext_lazy('subevent', 'Date'), - required=False, - empty_label=pgettext_lazy('subevent', 'All dates') - ) return d + +class CBFlowable(Flowable): + def __init__(self, checked=False): + self.checked = checked + super().__init__() + + def draw(self): + self.canv.rect(1 * mm, -4.5 * mm, 4 * mm, 4 * mm) + if self.checked: + self.canv.line(1.5 * mm, -4.0 * mm, 4.5 * mm, -1.0 * mm) + self.canv.line(1.5 * mm, -1.0 * mm, 4.5 * mm, -4.0 * mm) + + +class TableTextRotate(Flowable): + def __init__(self, text): + Flowable.__init__(self) + self.text = text + + def draw(self): + canvas = self.canv + canvas.rotate(90) + canvas.drawString(0, -1, self.text) + + +class PDFCheckinList(ReportlabExportMixin, BaseCheckinList): + name = "overview" + identifier = 'checkinlistpdf' + verbose_name = ugettext_lazy('Check-in list (PDF)') + + @property + def export_form_fields(self): + f = super().export_form_fields + del f['secrets'] + return f + + @property + def pagesize(self): + from reportlab.lib import pagesizes + + return pagesizes.landscape(pagesizes.A4) + + def get_story(self, doc, form_data): + cl = self.event.checkin_lists.get(pk=form_data['list']) + + questions = list(Question.objects.filter(event=self.event, id__in=form_data['questions'])) + + headlinestyle = self.get_style() + headlinestyle.fontSize = 15 + headlinestyle.fontName = 'OpenSansBd' + colwidths = [3 * mm, 8 * mm, 8 * mm] + [ + a * (doc.width - 8 * mm) + for a in [.1, .25, (.25 if questions else .60)] + ( + [.35 / len(questions)] * len(questions) if questions else [] + ) + ] + tstyledata = [ + ('VALIGN', (0, 0), (-1, 0), 'BOTTOM'), + ('ALIGN', (2, 0), (2, 0), 'CENTER'), + ('VALIGN', (0, 1), (-1, -1), 'TOP'), + ('FONTNAME', (0, 0), (-1, 0), 'OpenSansBd'), + ('ALIGN', (0, 0), (0, -1), 'CENTER'), + ('TEXTCOLOR', (0, 0), (0, -1), '#990000'), + ('FONTNAME', (0, 0), (0, -1), 'OpenSansBd'), + ] + + story = [ + Paragraph( + '{} – {}'.format(cl.name, (cl.subevent or self.event).get_date_from_display()), + headlinestyle + ), + Spacer(1, 5 * mm) + ] + + tdata = [ + [ + '', + '', + # Translators: maximum 5 characters + TableTextRotate(pgettext('tablehead', 'paid')), + _('Order'), + _('Name'), + _('Product') + '\n' + _('Price'), + ], + ] + + for q in questions: + tdata[0].append(str(q.question)) + + cqs = Checkin.objects.filter( + position_id=OuterRef('pk'), + list_id=cl.pk + ).order_by().values('position_id').annotate( + m=Max('datetime') + ).values('m') + + qs = OrderPosition.objects.filter( + order__event=self.event, + ).annotate( + last_checked_in=Subquery(cqs) + ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address').prefetch_related( + 'answers', 'answers__question' + ) + + if not cl.all_products: + qs = qs.filter(item__in=cl.limit_products.values_list('id', flat=True)) + + if cl.subevent: + qs = qs.filter(subevent=cl.subevent) + + if form_data['sort'] == 'name': + qs = qs.order_by(Coalesce('attendee_name', 'addon_to__attendee_name', 'order__invoice_address__name'), + 'order__code') + elif form_data['sort'] == 'code': + qs = qs.order_by('order__code') + + if form_data['paid_only']: + qs = qs.filter(order__status=Order.STATUS_PAID) + else: + qs = qs.filter(order__status__in=(Order.STATUS_PAID, Order.STATUS_PENDING)) + + for op in qs: + try: + ian = op.order.invoice_address.name + iac = op.order.invoice_address.company + except: + ian = "" + iac = "" + + name = op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '') or ian + if iac: + name += "\n" + iac + + row = [ + '!!' if op.item.checkin_attention else '', + CBFlowable(bool(op.last_checked_in)), + '✘' if op.order.status != Order.STATUS_PAID else '✔', + op.order.code, + name, + str(op.item.name) + (" – " + str(op.variation.value) if op.variation else "") + "\n" + + self.event.currency + " " + localize(op.price), + ] + acache = {} + for a in op.answers.all(): + acache[a.question_id] = str(a) + for q in questions: + row.append(acache.get(q.pk, '')) + if op.order.status != Order.STATUS_PAID: + tstyledata += [ + ('BACKGROUND', (2, len(tdata)), (2, len(tdata)), '#990000'), + ('TEXTCOLOR', (2, len(tdata)), (2, len(tdata)), '#ffffff'), + ('ALIGN', (2, len(tdata)), (2, len(tdata)), 'CENTER'), + ] + tdata.append(row) + + table = Table(tdata, colWidths=colwidths, repeatRows=1) + table.setStyle(TableStyle(tstyledata)) + story.append(table) + return story + + +class CSVCheckinList(BaseCheckinList): + name = "overview" + identifier = 'checkinlistcsv' + verbose_name = ugettext_lazy('Check-in list (CSV)') + def render(self, form_data: dict): output = io.StringIO() writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",") + cl = self.event.checkin_lists.get(pk=form_data['list']) questions = list(Question.objects.filter(event=self.event, id__in=form_data['questions'])) qs = OrderPosition.objects.filter( - order__event=self.event, item_id__in=form_data['items'] + order__event=self.event, ).prefetch_related( 'answers', 'answers__question' ).select_related('order', 'item', 'variation', 'addon_to') + if not cl.all_products: + qs = qs.filter(item__in=cl.limit_products.values_list('id', flat=True)) + + if cl.subevent: + qs = qs.filter(subevent=cl.subevent) + if form_data['sort'] == 'name': qs = qs.order_by(Coalesce('attendee_name', 'addon_to__attendee_name')) elif form_data['sort'] == 'code': @@ -95,8 +255,6 @@ class CSVCheckinList(BaseCheckinList): headers = [ _('Order code'), _('Attendee name'), _('Product'), _('Price') ] - if form_data.get('subevent'): - qs = qs.filter(subevent=form_data.get('subevent')) if form_data['paid_only']: qs = qs.filter(order__status=Order.STATUS_PAID) else: diff --git a/src/pretix/plugins/checkinlists/signals.py b/src/pretix/plugins/checkinlists/signals.py index c632e6ad93..fdf7dcde92 100644 --- a/src/pretix/plugins/checkinlists/signals.py +++ b/src/pretix/plugins/checkinlists/signals.py @@ -7,3 +7,9 @@ from pretix.base.signals import register_data_exporters def register_csv(sender, **kwargs): from .exporters import CSVCheckinList return CSVCheckinList + + +@receiver(register_data_exporters, dispatch_uid="export_checkinlist_pdf") +def register_pdf(sender, **kwargs): + from .exporters import PDFCheckinList + return PDFCheckinList diff --git a/src/pretix/plugins/pretixdroid/__init__.py b/src/pretix/plugins/pretixdroid/__init__.py index a1b4bf9f90..e210650c02 100644 --- a/src/pretix/plugins/pretixdroid/__init__.py +++ b/src/pretix/plugins/pretixdroid/__init__.py @@ -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): diff --git a/src/pretix/plugins/pretixdroid/forms.py b/src/pretix/plugins/pretixdroid/forms.py index 5a616ae1ea..04e94910e5 100644 --- a/src/pretix/plugins/pretixdroid/forms.py +++ b/src/pretix/plugins/pretixdroid/forms.py @@ -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() diff --git a/src/pretix/plugins/pretixdroid/migrations/0004_auto_20171124_1657.py b/src/pretix/plugins/pretixdroid/migrations/0004_auto_20171124_1657.py new file mode 100644 index 0000000000..65a6220daa --- /dev/null +++ b/src/pretix/plugins/pretixdroid/migrations/0004_auto_20171124_1657.py @@ -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'), + ), + ] diff --git a/src/pretix/plugins/pretixdroid/models.py b/src/pretix/plugins/pretixdroid/models.py index a5dcbb87c3..16dc876134 100644 --- a/src/pretix/plugins/pretixdroid/models.py +++ b/src/pretix/plugins/pretixdroid/models.py @@ -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: diff --git a/src/pretix/plugins/pretixdroid/signals.py b/src/pretix/plugins/pretixdroid/signals.py index b000789da5..36ccb57de6 100644 --- a/src/pretix/plugins/pretixdroid/signals.py +++ b/src/pretix/plugins/pretixdroid/signals.py @@ -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 + ) + ) diff --git a/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html b/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html index 7581030c2b..4ec46b86e6 100644 --- a/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html +++ b/src/pretix/plugins/pretixdroid/templates/pretixplugins/pretixdroid/configuration.html @@ -19,13 +19,11 @@
    {% 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 %}