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

@@ -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')

View File

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

View File

@@ -20,12 +20,13 @@
# <https://www.gnu.org/licenses/>.
#
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)

View File

@@ -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(

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.
"""
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``

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} 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.'),
})

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["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