forked from CGM_Public/pretix_original
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:
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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')
|
||||||
|
|||||||
@@ -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)),
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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``
|
||||||
|
|||||||
@@ -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.'),
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user