diff --git a/doc/api/resources/checkin.rst b/doc/api/resources/checkin.rst index 16e36424d1..89ab1cff11 100644 --- a/doc/api/resources/checkin.rst +++ b/doc/api/resources/checkin.rst @@ -359,3 +359,65 @@ Performing a ticket search :statuscode 401: Authentication failure :statuscode 403: The requested organizer or check-in list does not exist **or** you have no permission to view this resource. :statuscode 404: The requested check-in list does not exist. + +.. _`rest-checkin-annul`: + +Annulment of a check-in +----------------------- + +.. http:post:: /api/v1/organizers/(organizer)/checkinrpc/annul/ + + If a check-in was made in error and the person was not let in, it can be annulled. We do not recommend this to be used + in case of manual check-ins or user interfaces because it is too prone for human errors. It is mostly intended for + automated entry systems like a turnstile or automated door, where the check-in is first created, then the door is + opened, and then the check-in may be annulled if the system knows that the turnstile did not turn or was out of + order. + + This endpoint supports passing multiple check-in lists for the context of a multi-event scan. However, each + check-in list passed needs to be from a distinct event. + + Check-ins created by a device can only be annulled by the same device. The datetime of annulment may not be more than + 15 minutes after the datetime of check-in (value subject to change). + + A status code of 404 is returned if no check-in was found for the given nonce. A status code of 400 is returned when + multiple check-ins match the nonce, the input is invalid in another way, the annulment is made from the wrong device, + the check-in is already in an annulled or failed state, or the datetime constraint is not valid. + + :json string status: ``"ok"`` + + **Example request**: + + .. sourcecode:: http + + POST /api/v1/organizers/bigevents/checkinrpc/annul/ HTTP/1.1 + Host: pretix.eu + Accept: application/json, text/javascript + + { + "lists": [1], + "nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA", + "error_explanation": "Turnstile did not turn" + } + + **Example successful response**: + + .. sourcecode:: http + + HTTP/1.1 200 OK + Vary: Accept + Content-Type: application/json + + { + "status": "ok", + } + + :param organizer: The ``slug`` field of the organizer to fetch + :statuscode 200: no error + :statuscode 400: Invalid or incomplete request, see above + :statuscode 401: Authentication failure + :statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource. + :statuscode 404: The requested nonce does not exist. diff --git a/doc/development/api/general.rst b/doc/development/api/general.rst index c1d3a42022..b17f089131 100644 --- a/doc/development/api/general.rst +++ b/doc/development/api/general.rst @@ -30,7 +30,7 @@ Check-ins .. automodule:: pretix.base.signals :no-index: - :members: checkin_created + :members: checkin_created, checkin_annulled Frontend diff --git a/src/pretix/api/serializers/checkin.py b/src/pretix/api/serializers/checkin.py index 564665f3a6..c28dfada97 100644 --- a/src/pretix/api/serializers/checkin.py +++ b/src/pretix/api/serializers/checkin.py @@ -104,3 +104,14 @@ class MiniCheckinListSerializer(I18nAwareModelSerializer): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) + + +class CheckinRPCAnnulInputSerializer(serializers.Serializer): + lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none()) + nonce = serializers.CharField(required=True, allow_null=False) + datetime = serializers.DateTimeField(required=False, allow_null=True) + error_explanation = serializers.CharField(required=False, allow_null=True) + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields['lists'].child_relation.queryset = CheckinList.objects.filter(event__in=self.context['events']).select_related('event') diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index aa89ece052..b1cb10edd8 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -132,6 +132,8 @@ urlpatterns = [ name="checkinrpc.redeem"), re_path(r'^organizers/(?P[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(), name="checkinrpc.search"), + re_path(r'^organizers/(?P[^/]+)/checkinrpc/annul/$', checkin.CheckinRPCAnnulView.as_view(), + name="checkinrpc.annul"), re_path(r'^organizers/(?P[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(), name="organizer.settings"), re_path(r'^organizers/(?P[^/]+)/giftcards/(?P[^/]+)/', include(giftcard_router.urls)), diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 521687caa9..7f1621727a 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -20,12 +20,13 @@ # . # import operator +from datetime import timedelta from functools import reduce import django_filters from django.conf import settings from django.core.exceptions import ValidationError as BaseValidationError -from django.db import transaction +from django.db import connection, transaction from django.db.models import ( Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery, prefetch_related_objects, @@ -39,17 +40,19 @@ from django.utils.translation import gettext from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_scopes import scopes_disabled from packaging.version import parse -from rest_framework import views, viewsets +from rest_framework import status, views, viewsets from rest_framework.decorators import action -from rest_framework.exceptions import PermissionDenied, ValidationError +from rest_framework.exceptions import ( + NotFound, PermissionDenied, ValidationError, +) from rest_framework.fields import DateTimeField from rest_framework.generics import ListAPIView from rest_framework.permissions import SAFE_METHODS from rest_framework.response import Response from pretix.api.serializers.checkin import ( - CheckinListSerializer, CheckinRPCRedeemInputSerializer, - MiniCheckinListSerializer, + CheckinListSerializer, CheckinRPCAnnulInputSerializer, + CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer, ) from pretix.api.serializers.item import QuestionSerializer from pretix.api.serializers.order import ( @@ -66,6 +69,8 @@ from pretix.base.models.orders import PrintLog from pretix.base.services.checkin import ( CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, ) +from pretix.base.signals import checkin_annulled +from pretix.helpers import OF_SELF with scopes_disabled(): class CheckinListFilter(FilterSet): @@ -999,3 +1004,79 @@ class CheckinRPCSearchView(ListAPIView): qs = qs.none() return qs + + +class CheckinRPCAnnulView(views.APIView): + def post(self, request, *args, **kwargs): + if isinstance(self.request.auth, (TeamAPIToken, Device)): + events = self.request.auth.get_events_with_permission(('can_change_orders', 'can_checkin_orders')) + elif self.request.user.is_authenticated: + events = self.request.user.get_events_with_permission(('can_change_orders', 'can_checkin_orders'), self.request).filter( + organizer=self.request.organizer + ) + else: + raise ValueError("unknown authentication method") + + s = CheckinRPCAnnulInputSerializer(data=request.data, context={'events': events}) + s.is_valid(raise_exception=True) + + with transaction.atomic(): + try: + qs = Checkin.all.all() + if isinstance(request.auth, Device): + qs = qs.filter(device=request.auth) + ci = qs.select_for_update( + of=OF_SELF, + ).select_related("position", "position__order", "position__order__event").get( + list__in=s.validated_data['lists'], + nonce=s.validated_data['nonce'], + ) + if connection.features.has_select_for_update_of and ci.position_id: + # Lock position as well, can't do it with of= above because relation is nullable + OrderPosition.objects.select_for_update(of=OF_SELF).get(pk=ci.position_id) + + if not ci.successful or not ci.position: + raise ValidationError("Cannot annul an unsuccessful checkin") + except Checkin.DoesNotExist: + raise NotFound("No check-in found based on nonce") + except Checkin.MultipleObjectsReturned: + raise ValidationError("Multiple check-ins found based on nonce") + + annulment_time = s.validated_data.get("datetime") or now() + + if annulment_time - ci.datetime > timedelta(minutes=15): + # Compare to sent datetime, which makes this cheatable, but allows offline annulment of checkins + ci.position.order.log_action('pretix.event.checkin.annulment.ignored', data={ + 'checkin': ci.pk, + 'position': ci.position.id, + 'positionid': ci.position.positionid, + 'datetime': annulment_time, + 'error_explanation': s.validated_data.get("error_explanation"), + 'type': ci.type, + 'list': ci.list_id, + }, user=request.user, auth=request.auth) + return Response({ + "non_field_errors": ["Annulment is not allowed more than 15 minutes after check-in"] + }, status=status.HTTP_400_BAD_REQUEST) + + if ci.device and ci.device != request.auth: + return Response({ + "non_field_errors": ["Annulment is only allowed from the same device"] + }, status=status.HTTP_400_BAD_REQUEST) + + ci.successful = False + ci.error_reason = Checkin.REASON_ANNULLED + ci.error_explanation = s.validated_data.get("error_explanation") + ci.save(update_fields=["successful", "error_reason", "error_explanation"]) + ci.position.order.log_action('pretix.event.checkin.annulled', data={ + 'checkin': ci.pk, + 'position': ci.position.id, + 'positionid': ci.position.positionid, + 'datetime': annulment_time, + 'error_explanation': s.validated_data.get("error_explanation"), + 'type': ci.type, + 'list': ci.list_id, + }, user=request.user, auth=request.auth) + checkin_annulled.send(ci.position.order.event, checkin=ci) + + return Response({"status": "ok"}, status=status.HTTP_200_OK) diff --git a/src/pretix/base/models/checkin.py b/src/pretix/base/models/checkin.py index 9f42a9844d..e9e160d4b0 100644 --- a/src/pretix/base/models/checkin.py +++ b/src/pretix/base/models/checkin.py @@ -350,6 +350,7 @@ class Checkin(models.Model): REASON_BLOCKED = 'blocked' REASON_UNAPPROVED = 'unapproved' REASON_INVALID_TIME = 'invalid_time' + REASON_ANNULLED = 'annulled' REASONS = ( (REASON_CANCELED, _('Order canceled')), (REASON_INVALID, _('Unknown ticket')), @@ -364,6 +365,7 @@ class Checkin(models.Model): (REASON_BLOCKED, _('Ticket blocked')), (REASON_UNAPPROVED, _('Order not approved')), (REASON_INVALID_TIME, _('Ticket not valid at this time')), + (REASON_ANNULLED, _('Check-in annulled')), ) successful = models.BooleanField( diff --git a/src/pretix/base/signals.py b/src/pretix/base/signals.py index 252d4ac18a..054228401d 100644 --- a/src/pretix/base/signals.py +++ b/src/pretix/base/signals.py @@ -668,6 +668,16 @@ For backwards compatibility reasons, this signal is only sent when a **successfu As with all event-plugin signals, the ``sender`` keyword argument will contain the event. """ +checkin_annulled = EventPluginSignal() +""" +Arguments: ``checkin`` + +This signal is sent out every time a check-in is annulled (i.e. changed to unsuccessful after it +already was successful). + +As with all event-plugin signals, the ``sender`` keyword argument will contain the event. +""" + logentry_display = EventPluginSignal() """ Arguments: ``logentry`` diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 29fa97478f..544f0c7ee4 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -321,6 +321,14 @@ class OrderChangedSplitFrom(OrderLogEntryType): _('Denied scan of position #{posid} at {datetime} for list "{list}", type "{type}", error code "{errorcode}".'), _('Denied scan of position #{posid} for list "{list}", type "{type}", error code "{errorcode}".'), ), + 'pretix.event.checkin.annulled': ( + _('Annulled scan of position #{posid} at {datetime} for list "{list}", type "{type}".'), + _('Annulled scan of position #{posid} for list "{list}", type "{type}".'), + ), + 'pretix.event.checkin.annulment.ignored': ( + _('Ignored annulment of position #{posid} at {datetime} for list "{list}", type "{type}".'), + _('Ignored annulment of position #{posid} for list "{list}", type "{type}".'), + ), 'pretix.control.views.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'), 'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'), }) diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index d2fdd397de..9021c7c0fb 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -1077,3 +1077,97 @@ def test_reason_explanation_localization(token_client, organizer, clist, other_i assert resp.data["status"] == "error" assert resp.data["reason"] == "invalid_time" assert resp.data["reason_explanation"] == "Erst ab 01.01.2020 12:00 gültig." + + +@pytest.mark.django_db +def test_annul_simple(token_client, organizer, clist, event, order): + with scopes_disabled(): + p = order.positions.first() + resp = _redeem(token_client, organizer, clist, p.secret, { + 'nonce': 'nooooonce' + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + resp = token_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), { + 'lists': [clist.pk], + 'nonce': 'nooooonce', + 'error_explanation': 'Turnstile did not turn', + }, format='json', headers={}) + assert resp.status_code == 200 + + with scopes_disabled(): + ci = p.all_checkins.get() + assert not ci.successful + assert ci.error_reason == Checkin.REASON_ANNULLED + assert ci.error_explanation == "Turnstile did not turn" + + +@pytest.mark.django_db +def test_annul_failures(device_client, team, organizer, clist, clist_event2, event, order): + with scopes_disabled(): + p = order.positions.first() + resp = _redeem(device_client, organizer, clist, p.secret, { + 'nonce': 'nooooonce', + 'datetime': '2025-04-01T12:23:45Z', + }) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + + resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), { + 'lists': [clist.pk], + }, format='json', headers={}) + assert resp.status_code == 400 + assert resp.data == {"nonce": ["This field is required."]} + + resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), { + 'lists': [clist_event2.pk], + 'nonce': 'nooooonce', + 'error_explanation': 'Turnstile did not turn', + }, format='json', headers={}) + assert resp.status_code == 404 + assert resp.data == {"detail": "No check-in found based on nonce"} + + resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), { + 'lists': [clist.pk], + 'nonce': 'notfound', + 'error_explanation': 'Turnstile did not turn', + }, format='json', headers={}) + assert resp.status_code == 404 + assert resp.data == {"detail": "No check-in found based on nonce"} + + with scopes_disabled(): + lcnt = order.all_logentries().count() + + resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), { + 'lists': [clist.pk], + 'nonce': 'nooooonce', + 'error_explanation': 'Turnstile did not turn', + }, format='json', headers={}) + assert resp.status_code == 400 + assert resp.data == { + 'non_field_errors': ['Annulment is not allowed more than 15 minutes after check-in'] + } + + with scopes_disabled(): + assert order.all_logentries().count() == lcnt + 1 + + t = team.tokens.create(name='Foo') + team.all_events = True + team.save() + device_client.credentials(HTTP_AUTHORIZATION='Token ' + t.token) + + resp = device_client.post('/api/v1/organizers/{}/checkinrpc/annul/'.format(organizer.slug), { + 'lists': [clist.pk], + 'nonce': 'nooooonce', + 'error_explanation': 'Turnstile did not turn', + 'datetime': '2025-04-01T12:24:45Z', + }, format='json', headers={}) + assert resp.status_code == 400 + assert resp.data == { + 'non_field_errors': ['Annulment is only allowed from the same device'] + } + + with scopes_disabled(): + ci = p.all_checkins.get() + assert ci.successful