diff --git a/doc/images/checkin_online.png b/doc/images/checkin_online.png index 6c9d07d57..e05d9bccf 100644 Binary files a/doc/images/checkin_online.png and b/doc/images/checkin_online.png differ diff --git a/doc/images/checkin_online.puml b/doc/images/checkin_online.puml index baeaf0d9e..fbdc63e22 100644 --- a/doc/images/checkin_online.puml +++ b/doc/images/checkin_online.puml @@ -38,27 +38,27 @@ else endif "Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then - -right->[no] "Return error CANCELED" + -right->[no && !force] "Return error CANCELED" else - -down->[yes] "Is one or more block set on the ticket?" + -down->[yes || force] "Is one or more block set on the ticket?" --> if "" then - -right->[no] "Return error BLOCKED" + -right->[no && !force] "Return error BLOCKED" else - -down->[yes] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?" + -down->[yes || force] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?" --> if "" then - -right->[no] "Return error INVALID_TIME" + -right->[no && !force] "Return error INVALID_TIME" else - -down->[yes] "Is the product part of the check-in list?" + -down->[yes || force] "Is the product part of the check-in list?" --> if "" then - -right->[no] "Return error PRODUCT" + -right->[no && !force] "Return error PRODUCT" else - -down->[yes] "Is the subevent part of the check-in list?" + -down->[yes || force] "Is the subevent part of the check-in list?" --> if "" then - -right->[no] "Return error PRODUCT " + -right->[no && !force] "Return error PRODUCT " else - -down->[yes] "Is the order in status PAID\nor is this a forced upload?" + -down->[yes] "Is the order in status PAID?" --> if "" then - -right->[no] "Is Order.require_approval set?" + -right->[no && !force] "Is Order.require_approval set?" --> if "" then -->[no] "Is Order.valid_if_pending set?" --> if "" then @@ -80,7 +80,7 @@ else -->[yes] "Return error UNPAID " endif else - -down->[yes] "Is this an entry or exit?\nIs the upload forced?" + -down->[yes || force] "Is this an entry or exit?\nIs the upload forced?" endif endif endif diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 057c5c366..131a2d67e 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -714,40 +714,53 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, # !!!!!!!!! dt = datetime or now() + force_used = False if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING): - raise CheckInError( - _('This order position has been canceled.'), - 'canceled' if canceled_supported else 'unpaid' - ) + if force: + force_used = True + else: + raise CheckInError( + _('This order position has been canceled.'), + 'canceled' if canceled_supported else 'unpaid' + ) if op.blocked: - raise CheckInError( - _('This ticket has been blocked.'), # todo provide reason - 'blocked' - ) + if force: + force_used = True + else: + raise CheckInError( + _('This ticket has been blocked.'), # todo provide reason + 'blocked' + ) if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > now(): - raise CheckInError( - _('This ticket is only valid after {datetime}.').format( - datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') - ), - 'invalid_time', - _('This ticket is only valid after {datetime}.').format( - datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') - ), - ) + if force: + force_used = True + else: + raise CheckInError( + _('This ticket is only valid after {datetime}.').format( + datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') + ), + 'invalid_time', + _('This ticket is only valid after {datetime}.').format( + datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT') + ), + ) if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < now(): - raise CheckInError( - _('This ticket was only valid before {datetime}.').format( - datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') - ), - 'invalid_time', - _('This ticket was only valid before {datetime}.').format( - datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') - ), - ) + if force: + force_used = True + else: + raise CheckInError( + _('This ticket was only valid before {datetime}.').format( + datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') + ), + 'invalid_time', + _('This ticket was only valid before {datetime}.').format( + datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT') + ), + ) # Do this outside of transaction so it is saved even if the checkin fails for some other reason checkin_questions = list( @@ -770,40 +783,57 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, op = opqs.get(pk=op.pk) if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]: - raise CheckInError( - _('This order position has an invalid product for this check-in list.'), - 'product' - ) - elif clist.subevent_id and op.subevent_id != clist.subevent_id: - raise CheckInError( - _('This order position has an invalid date for this check-in list.'), - 'product' - ) - elif op.order.status != Order.STATUS_PAID and not force and op.order.require_approval: - raise CheckInError( - _('This order is not yet approved.'), - 'unpaid' - ) - elif op.order.status != Order.STATUS_PAID and not force and not op.order.valid_if_pending and not ( + if force: + force_used = True + else: + raise CheckInError( + _('This order position has an invalid product for this check-in list.'), + 'product' + ) + + if clist.subevent_id and op.subevent_id != clist.subevent_id: + if force: + force_used = True + else: + raise CheckInError( + _('This order position has an invalid date for this check-in list.'), + 'product' + ) + + if op.order.status != Order.STATUS_PAID and op.order.require_approval: + if force: + force_used = True + else: + raise CheckInError( + _('This order is not yet approved.'), + 'unpaid' + ) + elif op.order.status != Order.STATUS_PAID and not op.order.valid_if_pending and not ( ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING ): - raise CheckInError( - _('This order is not marked as paid.'), - 'unpaid' - ) + if force: + force_used = True + else: + raise CheckInError( + _('This order is not marked as paid.'), + 'unpaid' + ) - if type == Checkin.TYPE_ENTRY and clist.rules and not force: + if type == Checkin.TYPE_ENTRY and clist.rules: rule_data = LazyRuleVars(op, clist, dt) logic = _get_logic_environment(op.subevent or clist.event) if not logic.apply(clist.rules, rule_data): - reason = _logic_explain(clist.rules, op.subevent or clist.event, rule_data) - raise CheckInError( - _('Entry not permitted: {explanation}.').format( - explanation=reason - ), - 'rules', - reason=reason - ) + if force: + force_used = True + else: + reason = _logic_explain(clist.rules, op.subevent or clist.event, rule_data) + raise CheckInError( + _('Entry not permitted: {explanation}.').format( + explanation=reason + ), + 'rules', + reason=reason + ) if require_answers and not force and questions_supported: raise RequiredQuestionsError( @@ -837,7 +867,7 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, device=device, gate=device.gate if device else None, nonce=nonce, - forced=force and (not entry_allowed or from_revoked_secret), + forced=force and (not entry_allowed or from_revoked_secret or force_used), force_sent=force, raw_barcode=raw_barcode, ) diff --git a/src/tests/api/test_checkinrpc.py b/src/tests/api/test_checkinrpc.py index 2f64d835b..922ddccea 100644 --- a/src/tests/api/test_checkinrpc.py +++ b/src/tests/api/test_checkinrpc.py @@ -356,6 +356,24 @@ def test_forced_multiple(token_client, organizer, clist, event, order): assert resp.data['status'] == 'ok' +@pytest.mark.django_db +def test_forced_canceled(token_client, organizer, clist, event, order): + order.status = Order.STATUS_CANCELED + order.save() + with scopes_disabled(): + p = order.positions.first() + resp = _redeem(token_client, organizer, clist, p.secret, {}) + assert resp.status_code == 400 + assert resp.data['status'] == 'error' + resp = _redeem(token_client, organizer, clist, p.secret, {'force': True}) + assert resp.status_code == 201 + assert resp.data['status'] == 'ok' + with scopes_disabled(): + ci = p.checkins.get() + assert ci.force_sent + assert ci.forced + + @pytest.mark.django_db def test_forced_flag_set_if_required(token_client, organizer, clist, event, order): with scopes_disabled(): diff --git a/src/tests/base/test_checkin.py b/src/tests/base/test_checkin.py index 570fbce95..eeaa0ee23 100644 --- a/src/tests/base/test_checkin.py +++ b/src/tests/base/test_checkin.py @@ -100,6 +100,8 @@ def test_checkin_canceled_order(position, clist): perform_checkin(position, clist, {}, canceled_supported=True) assert excinfo.value.code == 'canceled' assert position.checkins.count() == 0 + perform_checkin(position, clist, {}, canceled_supported=True, force=True) + assert position.checkins.count() == 1 @pytest.mark.django_db @@ -127,6 +129,8 @@ def test_checkin_blocked_position(position, clist): perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) assert excinfo.value.code == 'blocked' assert position.checkins.count() == 0 + perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT, force=True) + assert position.checkins.count() == 1 @pytest.mark.django_db @@ -139,9 +143,12 @@ def test_checkin_valid_from(event, position, clist): assert excinfo.value.code == 'invalid_time' assert excinfo.value.reason == 'This ticket is only valid after 2020-01-01 12:00.' assert position.checkins.count() == 0 + # Force is allowed + perform_checkin(position, clist, {}, force=True) + assert position.checkins.count() == 1 perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) - assert position.checkins.count() == 1 + assert position.checkins.count() == 2 @pytest.mark.django_db @@ -154,18 +161,25 @@ def test_checkin_valid_until(event, position, clist): assert excinfo.value.code == 'invalid_time' assert excinfo.value.reason == 'This ticket was only valid before 2020-01-01 09:00.' assert position.checkins.count() == 0 + # Force is allowed + perform_checkin(position, clist, {}, force=True) + assert position.checkins.count() == 1 perform_checkin(position, clist, {}, type=Checkin.TYPE_EXIT) - assert position.checkins.count() == 1 + assert position.checkins.count() == 2 @pytest.mark.django_db def test_checkin_invalid_product(position, clist): clist.all_products = False + clist.allow_multiple_entries = True clist.save() with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}) assert excinfo.value.code == 'product' + + perform_checkin(position, clist, {}, force=True) + clist.limit_products.add(position.item) perform_checkin(position, clist, {}) @@ -185,6 +199,8 @@ def test_checkin_invalid_subevent(position, clist, event): perform_checkin(position, clist, {}) assert excinfo.value.code == 'product' + perform_checkin(position, clist, {}, force=True) + @pytest.mark.django_db def test_checkin_all_subevents(position, clist, event): @@ -228,6 +244,8 @@ def test_require_approval(position, clist): with pytest.raises(CheckInError) as excinfo: perform_checkin(position, clist, {}, ignore_unpaid=True) assert excinfo.value.code == 'unpaid' + perform_checkin(position, clist, {}, ignore_unpaid=True, force=True) + assert position.checkins.count() == 1 @pytest.mark.django_db