Allow to annul a check-in (#5303)

* Allow to annul a check-in

* Fix locking

* Update doc/api/resources/checkin.rst

Co-authored-by: Phin Wolkwitz <wolkwitz@rami.io>

---------

Co-authored-by: Phin Wolkwitz <wolkwitz@rami.io>
This commit is contained in:
Raphael Michel
2025-08-08 09:22:19 +02:00
committed by GitHub
parent b4264c0ae7
commit 067e11c265
9 changed files with 276 additions and 6 deletions

View File

@@ -359,3 +359,65 @@ Performing a ticket search
:statuscode 401: Authentication failure :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 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. :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 nonce: ``nonce`` value of the original check-in.
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
:<json datetime datetime: Specifies the client-side datetime of the annulment. If not supplied, the current time will be used.
:<json string error_explanation: A human-readable description of why the check-in was annulled (optional).
:>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.

View File

@@ -30,7 +30,7 @@ Check-ins
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:no-index: :no-index:
:members: checkin_created :members: checkin_created, checkin_annulled
Frontend Frontend

View File

@@ -104,3 +104,14 @@ class MiniCheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*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')

View File

@@ -132,6 +132,8 @@ urlpatterns = [
name="checkinrpc.redeem"), name="checkinrpc.redeem"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(), re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/search/$', checkin.CheckinRPCSearchView.as_view(),
name="checkinrpc.search"), name="checkinrpc.search"),
re_path(r'^organizers/(?P<organizer>[^/]+)/checkinrpc/annul/$', checkin.CheckinRPCAnnulView.as_view(),
name="checkinrpc.annul"),
re_path(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(), re_path(r'^organizers/(?P<organizer>[^/]+)/settings/$', organizer.OrganizerSettingsView.as_view(),
name="organizer.settings"), name="organizer.settings"),
re_path(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)), re_path(r'^organizers/(?P<organizer>[^/]+)/giftcards/(?P<giftcard>[^/]+)/', include(giftcard_router.urls)),

View File

@@ -20,12 +20,13 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import operator import operator
from datetime import timedelta
from functools import reduce from functools import reduce
import django_filters import django_filters
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError as BaseValidationError from django.core.exceptions import ValidationError as BaseValidationError
from django.db import transaction from django.db import connection, transaction
from django.db.models import ( from django.db.models import (
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery, Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
prefetch_related_objects, prefetch_related_objects,
@@ -39,17 +40,19 @@ from django.utils.translation import gettext
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from packaging.version import parse 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.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.fields import DateTimeField
from rest_framework.generics import ListAPIView from rest_framework.generics import ListAPIView
from rest_framework.permissions import SAFE_METHODS from rest_framework.permissions import SAFE_METHODS
from rest_framework.response import Response from rest_framework.response import Response
from pretix.api.serializers.checkin import ( from pretix.api.serializers.checkin import (
CheckinListSerializer, CheckinRPCRedeemInputSerializer, CheckinListSerializer, CheckinRPCAnnulInputSerializer,
MiniCheckinListSerializer, CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer,
) )
from pretix.api.serializers.item import QuestionSerializer from pretix.api.serializers.item import QuestionSerializer
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
@@ -66,6 +69,8 @@ from pretix.base.models.orders import PrintLog
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin, CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
) )
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
with scopes_disabled(): with scopes_disabled():
class CheckinListFilter(FilterSet): class CheckinListFilter(FilterSet):
@@ -999,3 +1004,79 @@ class CheckinRPCSearchView(ListAPIView):
qs = qs.none() qs = qs.none()
return qs 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)

View File

@@ -350,6 +350,7 @@ class Checkin(models.Model):
REASON_BLOCKED = 'blocked' REASON_BLOCKED = 'blocked'
REASON_UNAPPROVED = 'unapproved' REASON_UNAPPROVED = 'unapproved'
REASON_INVALID_TIME = 'invalid_time' REASON_INVALID_TIME = 'invalid_time'
REASON_ANNULLED = 'annulled'
REASONS = ( REASONS = (
(REASON_CANCELED, _('Order canceled')), (REASON_CANCELED, _('Order canceled')),
(REASON_INVALID, _('Unknown ticket')), (REASON_INVALID, _('Unknown ticket')),
@@ -364,6 +365,7 @@ class Checkin(models.Model):
(REASON_BLOCKED, _('Ticket blocked')), (REASON_BLOCKED, _('Ticket blocked')),
(REASON_UNAPPROVED, _('Order not approved')), (REASON_UNAPPROVED, _('Order not approved')),
(REASON_INVALID_TIME, _('Ticket not valid at this time')), (REASON_INVALID_TIME, _('Ticket not valid at this time')),
(REASON_ANNULLED, _('Check-in annulled')),
) )
successful = models.BooleanField( successful = models.BooleanField(

View File

@@ -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. 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() logentry_display = EventPluginSignal()
""" """
Arguments: ``logentry`` Arguments: ``logentry``

View File

@@ -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} at {datetime} for list "{list}", type "{type}", error code "{errorcode}".'),
_('Denied scan of position #{posid} 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.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.'), 'pretix.event.checkin.reverted': _('The check-in of position #{posid} on list "{list}" has been reverted.'),
}) })

View File

@@ -1077,3 +1077,97 @@ def test_reason_explanation_localization(token_client, organizer, clist, other_i
assert resp.data["status"] == "error" assert resp.data["status"] == "error"
assert resp.data["reason"] == "invalid_time" assert resp.data["reason"] == "invalid_time"
assert resp.data["reason_explanation"] == "Erst ab 01.01.2020 12:00 gültig." 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