Check-in API: Improve handling of unknown ticket codes

This commit is contained in:
Raphael Michel
2021-07-23 10:49:33 +02:00
parent 78f4f35ca3
commit 4655d8237f
5 changed files with 123 additions and 7 deletions

View File

@@ -618,8 +618,9 @@ Order position endpoints
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be :<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
returned. Otherwise, canceled orders will return ``unpaid``. returned. Otherwise, canceled orders will return ``unpaid``.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used. :<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required :<json boolean force: Specifies that the check-in should succeed regardless of revoked barcode, previous check-ins or required
questions that have not been filled. Defaults to ``false``. questions that have not been filled. This is usually used to upload offline scans that already happened,
because there's no point in validating them since they happend whether they are valid or not. Defaults to ``false``.
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry. :<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state. :<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
Defaults to ``false`` and only works when ``include_pending`` is set on the check-in Defaults to ``false`` and only works when ``include_pending`` is set on the check-in

View File

@@ -32,6 +32,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
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 rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import DateTimeField from rest_framework.fields import DateTimeField
@@ -421,6 +422,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
nonce=nonce, nonce=nonce,
forced=force, forced=force,
) )
raw_barcode_for_checkin = None
try: try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True) queryset = self.get_queryset(ignore_status=True, ignore_products=True)
@@ -455,7 +457,41 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
error_reason=Checkin.REASON_INVALID, error_reason=Checkin.REASON_INVALID,
**common_checkin_args, **common_checkin_args,
) )
raise Http404()
if force and isinstance(self.request.auth, Device):
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
# valid at the time but no longer exists at time of upload, the device would retry to
# upload the same scan over and over again. Since we can't update all devices quickly,
# here's a dirty workaround to make it stop.
try:
brand = self.request.auth.software_brand
ver = parse(self.request.auth.software_version)
legacy_mode = (
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
)
if legacy_mode:
return Response({
'status': 'error',
'reason': Checkin.REASON_ALREADY_REDEEMED,
'reason_explanation': None,
'require_attention': False,
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
}, status=400)
except: # we don't care e.g. about invalid version numbers
pass
return Response({
'detail': 'Not found.', # for backwards compatibility
'status': 'error',
'reason': Checkin.REASON_INVALID,
'reason_explanation': None,
'require_attention': False,
}, status=404)
elif revoked_matches and force:
op = revoked_matches[0].position
raw_barcode_for_checkin = self.kwargs['pk']
else: else:
op = revoked_matches[0].position op = revoked_matches[0].position
op.order.log_action('pretix.event.checkin.revoked', data={ op.order.log_action('pretix.event.checkin.revoked', data={
@@ -506,7 +542,8 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
type=type, type=type,
raw_barcode=None, raw_barcode=raw_barcode_for_checkin,
from_revoked_secret=True,
) )
except RequiredQuestionsError as e: except RequiredQuestionsError as e:
return Response({ return Response({

View File

@@ -567,7 +567,7 @@ def _save_answers(op, answers, given_answers):
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY, user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None): raw_barcode=None, from_revoked_secret=False):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -669,7 +669,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
device=device, device=device,
gate=device.gate if device else None, gate=device.gate if device else None,
nonce=nonce, nonce=nonce,
forced=force and not entry_allowed, forced=force and (not entry_allowed or from_revoked_secret),
raw_barcode=raw_barcode, raw_barcode=raw_barcode,
) )
op.order.log_action('pretix.event.checkin', data={ op.order.log_action('pretix.event.checkin', data={

View File

@@ -45,7 +45,7 @@ def validate_plan_change(event, subevent, plan):
seat=OuterRef('pk'), seat=OuterRef('pk'),
canceled=False, canceled=False,
).exclude( ).exclude(
order__status=Order.STATUS_CANCELED order__status=(Order.STATUS_CANCELED, Order.STATUS_EXPIRED)
)) ))
).annotate(has_v=Count('vouchers')).filter( ).annotate(has_v=Count('vouchers')).filter(
subevent=subevent, subevent=subevent,

View File

@@ -1106,3 +1106,81 @@ def test_store_failed(token_client, organizer, clist, event, order):
'error_reason': 'unknown' 'error_reason': 'unknown'
}, format='json') }, format='json')
assert resp.status_code == 400 assert resp.status_code == 400
@pytest.mark.django_db
def test_redeem_unknown(token_client, organizer, clist, event, order):
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'unknown_secret'
), {
'force': True
}, format='json')
assert resp.status_code == 404
assert resp.data["status"] == "error"
assert resp.data["reason"] == "invalid"
with scopes_disabled():
assert not Checkin.objects.last()
@pytest.mark.django_db
def test_redeem_unknown_revoked(token_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
event.revoked_secrets.create(position=p, secret='revoked_secret')
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'revoked_secret'
), {
}, format='json')
assert resp.status_code == 400
assert resp.data["status"] == "error"
assert resp.data["reason"] == "revoked"
with scopes_disabled():
assert not Checkin.objects.last()
@pytest.mark.django_db
def test_redeem_unknown_revoked_force(token_client, organizer, clist, event, order):
with scopes_disabled():
p = order.positions.first()
event.revoked_secrets.create(position=p, secret='revoked_secret')
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'revoked_secret'
), {
'force': True
}, format='json')
assert resp.status_code == 201
assert resp.data["status"] == "ok"
with scopes_disabled():
assert Checkin.objects.last().forced
@pytest.mark.django_db
def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clist, event, order):
device.software_brand = "pretixSCAN"
device.software_version = "1.11.1"
device.save()
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'unknown_secret'
), {
'force': True
}, format='json')
print(resp.data)
assert resp.status_code == 400
assert resp.data["status"] == "error"
assert resp.data["reason"] == "already_redeemed"
with scopes_disabled():
assert not Checkin.objects.last()
device.software_brand = "pretixSCAN"
device.software_version = "1.11.2"
device.save()
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'unknown_secret'
), {
'force': True
}, format='json')
assert resp.status_code == 404
assert resp.data["status"] == "error"
assert resp.data["reason"] == "invalid"
with scopes_disabled():
assert not Checkin.objects.last()