Compare commits

...

1 Commits

Author SHA1 Message Date
Raphael Michel
ec48c2a373 Add option to scan add-on based on its parent position's secret 2022-07-06 10:15:19 +02:00
14 changed files with 337 additions and 50 deletions

View File

@@ -34,6 +34,7 @@ allow_multiple_entries boolean If ``true``, su
allow_entry_after_exit boolean If ``true``, subsequent scans of a ticket on this list are valid if the last scan of the ticket was an exit scan.
rules object Custom check-in logic. The contents of this field are currently not considered a stable API and modifications through the API are highly discouraged.
exit_all_at datetime Automatically check out (i.e. perform an exit scan) at this point in time. After this happened, this property will automatically be set exactly one day into the future. Note that this field is considered "internal configuration" and if you pull the list with ``If-Modified-Since``, the daily change in this field will not trigger a response.
addon_match boolean If ``true``, tickets on this list can be redeemed by scanning their parent ticket if this still leads to an unambiguous match.
===================================== ========================== =======================================================
.. versionchanged:: 3.9
@@ -53,6 +54,10 @@ exit_all_at datetime Automatically c
The ``ends_after`` and ``expand`` query parameters have been added.
.. versionchanged:: 4.12
The ``addon_match`` attribute has been added.
Endpoints
---------
@@ -94,6 +99,7 @@ Endpoints
"allow_entry_after_exit": true,
"exit_all_at": null,
"rules": {},
"addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
@@ -146,6 +152,7 @@ Endpoints
"allow_entry_after_exit": true,
"exit_all_at": null,
"rules": {},
"addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
@@ -245,6 +252,7 @@ Endpoints
"subevent": null,
"allow_multiple_entries": false,
"allow_entry_after_exit": true,
"addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
@@ -269,6 +277,7 @@ Endpoints
"subevent": null,
"allow_multiple_entries": false,
"allow_entry_after_exit": true,
"addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
@@ -323,6 +332,7 @@ Endpoints
"subevent": null,
"allow_multiple_entries": false,
"allow_entry_after_exit": true,
"addon_match": false,
"auto_checkin_sales_channels": [
"pretixpos"
]
@@ -743,6 +753,7 @@ Order position endpoints
* ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device
* ``rules`` - Check-in prevented by a user-defined rule
* ``ambiguous`` - Multiple tickets match scan, rejected
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable
description of the violated rules. However, that field can also be missing or be ``null``.

Binary file not shown.

Before

Width:  |  Height:  |  Size: 236 KiB

After

Width:  |  Height:  |  Size: 274 KiB

View File

@@ -2,8 +2,25 @@
partition "data-based check" {
"Check based on local database" --> "Is the order in status PAID or PENDING\nand is the position not canceled?"
"Check based on local database" -down-> "Is addon_match set to true?"
--> if "" then
-down->[no] "Is the order in status PAID or PENDING\nand is the position not canceled?"
else
-right->[yes] "Build a list that includes the position\nas well as all its add-ons"
-down-> "Filter list for products that are part of the check-in list"
--> if "" then
-down->[one found] Proceed with the matching position
--> "Is the order in status PAID or PENDING\nand is the position not canceled?"
else
--> if "" then
-right->[none found] "Return error PRODUCT "
else
-down->[multiple found] Return error AMBIGUOUS
endif
endif
endif
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
-right->[no] "Return error CANCELED"
else
-down->[yes] "Is the product part of the check-in list?"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 175 KiB

View File

@@ -19,8 +19,25 @@ else
endif
===CHECK=== -down-> "Is the order in status PAID or PENDING\nand is the position not canceled?"
===CHECK=== -down-> "Is addon_match set to true?"
--> if "" then
-down->[no] "Is the order in status PAID or PENDING\nand is the position not canceled?"
else
-right->[yes] "Build a list that includes the position\nas well as all its add-ons"
-down-> "Filter list for products that are part of the check-in list"
--> if "" then
-down->[one found] Proceed with the matching position
--> "Is the order in status PAID or PENDING\nand is the position not canceled?"
else
--> if "" then
-right->[none found] "Return error PRODUCT "
else
-down->[multiple found] Return error AMBIGUOUS
endif
endif
endif
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
-right->[no] "Return error CANCELED"
else
-down->[yes] "Is the product part of the check-in list?"

View File

@@ -90,6 +90,7 @@ Name and seat visible on scanner yes
Order-specific check-in attention flag yes yes yes (except directly after sale) n/a no
Ticket search by order code or name yes yes yes (except directly after sale) no no
Check-in statistics on scanner yes yes mostly accurate no no
Support for add-on check-in with main ticket yes yes yes (except directly after sale) no no
=============================================== =================================== =================================== =================================== ================================= =====================================
.. _EdDSA: https://en.wikipedia.org/wiki/EdDSA#Ed25519

View File

@@ -37,7 +37,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending', 'auto_checkin_sales_channels', 'allow_multiple_entries', 'allow_entry_after_exit',
'rules', 'exit_all_at')
'rules', 'exit_all_at', 'addon_match')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)

View File

@@ -25,6 +25,7 @@ from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import (
Count, Exists, F, Max, OrderBy, OuterRef, Prefetch, Q, Subquery,
prefetch_related_objects,
)
from django.db.models.functions import Coalesce
from django.http import Http404
@@ -280,7 +281,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.all.none()
filter_backends = (ExtendedBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid')
ordering = (F('attendee_name_cached').asc(nulls_last=True), 'positionid')
ordering_fields = (
'order__code', 'order__datetime', 'positionid', 'attendee_name',
'last_checked_in', 'order__email',
@@ -408,12 +409,14 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
raise ValidationError("Invalid check-in type.")
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
nonce = self.request.data.get('nonce')
untrusted_input = (
self.request.GET.get('untrusted_input', '') not in ('0', 'false', 'False', '')
or (isinstance(self.request.auth, Device) and 'pretixscan' in (self.request.auth.software_brand or '').lower())
)
if not self.checkinlist.all_products:
prefetch_related_objects([self.checkinlist], 'limit_products')
if 'datetime' in self.request.data:
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
else:
@@ -432,19 +435,32 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
raw_barcode_for_checkin = None
from_revoked_secret = False
try:
queryset = self.get_queryset(ignore_status=True, ignore_products=True)
if self.kwargs['pk'].isnumeric() and not untrusted_input:
op = queryset.get(Q(pk=self.kwargs['pk']) | Q(secret=self.kwargs['pk']))
else:
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
# `id`, however, is part of a path where this technically is not allowed. Old versions of our
# scan apps still do it, so we try work around it!
try:
op = queryset.get(secret=self.kwargs['pk'])
except OrderPosition.DoesNotExist:
op = queryset.get(secret=self.kwargs['pk'].replace('+', ' '))
except OrderPosition.DoesNotExist:
# 1. Gather a list of positions that could be the one we looking fore, either from their ID, secret or
# parent secret
queryset = self.get_queryset(ignore_status=True, ignore_products=True).order_by(
F('addon_to').asc(nulls_first=True)
)
q = Q(secret=self.kwargs['pk'])
if self.checkinlist.addon_match:
q |= Q(addon_to__secret=self.kwargs['pk'])
if self.kwargs['pk'].isnumeric() and not untrusted_input:
q |= Q(pk=self.kwargs['pk'])
op_candidates = list(queryset.filter(q))
if not op_candidates and '+' in self.kwargs['pk']:
# In application/x-www-form-urlencoded, you can encodes space ' ' with '+' instead of '%20'.
# `id`, however, is part of a path where this technically is not allowed. Old versions of our
# scan apps still do it, so we try work around it!
q = Q(secret=self.kwargs['pk'].replace('+', ' '))
if self.checkinlist.addon_match:
q |= Q(addon_to__secret=self.kwargs['pk'].replace('+', ' '))
op_candidates = list(queryset.filter(q))
# 2. Handle the "nothing found" case: Either it's really a bogus secret that we don't know (-> error), or it
# might be a revoked one that we actually know (-> error, but with better error message and logging and
# with respecting the force option).
if not op_candidates:
revoked_matches = list(self.request.event.revoked_secrets.filter(secret=self.kwargs['pk']))
if len(revoked_matches) == 0:
self.request.event.log_action('pretix.event.checkin.unknown', data={
@@ -504,7 +520,9 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'require_attention': False,
}, status=404)
elif revoked_matches and force:
op = revoked_matches[0].position
op_candidates = [revoked_matches[0].position]
if self.checkinlist.addon_match:
op_candidates += list(revoked_matches[0].position.addons.all())
raw_barcode_for_checkin = self.kwargs['pk']
from_revoked_secret = True
else:
@@ -529,6 +547,56 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=400)
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
# which add-on has the right product.
if len(op_candidates) > 1:
if self.checkinlist.addon_match and not self.checkinlist.all_products:
op_candidates_matching_product = [
op for op in op_candidates if op.item_id in {i.pk for i in self.checkinlist.limit_products.all()}
]
else:
op_candidates_matching_product = op_candidates
if len(op_candidates_matching_product) == 0:
# None of the found add-ons has the correct product, too bad! We could just error out here, but
# instead we just continue with *any* product and have it rejected by the check in perform_checkin.
# This has the advantage of a better error message.
op_candidates = [op_candidates[0]]
elif len(op_candidates_matching_product) > 1:
# It's still ambiguous, we'll error out.
# We choose the first match (regardless of product) for the logging since it's most likely to be the
# base product according to our order_by above.
op = op_candidates[0]
op.order.log_action('pretix.event.checkin.denied', data={
'position': op.id,
'positionid': op.positionid,
'errorcode': Checkin.REASON_AMBIGUOUS,
'reason_explanation': None,
'force': force,
'datetime': dt,
'type': type,
'list': self.checkinlist.pk
}, user=self.request.user, auth=self.request.auth)
Checkin.objects.create(
position=op,
successful=False,
error_reason=Checkin.REASON_AMBIGUOUS,
error_explanation=None,
**common_checkin_args,
)
return Response({
'status': 'error',
'reason': Checkin.REASON_AMBIGUOUS,
'reason_explanation': None,
'require_attention': op.item.checkin_attention or op.order.checkin_attention,
'position': CheckinListOrderPositionSerializer(op, context=self.get_serializer_context()).data
}, status=400)
else:
op_candidates = op_candidates_matching_product
op = op_candidates[0]
# 5. Pre-validate all incoming answers, handle file upload
given_answers = {}
if 'answers' in self.request.data:
aws = self.request.data.get('answers')
@@ -542,6 +610,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
except ValidationError:
pass
# 6. Pass to our actual check-in logic
with language(self.request.event.settings.locale):
try:
perform_checkin(

View File

@@ -0,0 +1,18 @@
# Generated by Django 3.2.2 on 2022-06-29 17:37
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0217_eventfooterlink_organizerfooterlink'),
]
operations = [
migrations.AddField(
model_name='checkinlist',
name='addon_match',
field=models.BooleanField(default=False),
),
]

View File

@@ -56,6 +56,12 @@ class CheckinList(LoggedModel):
default=False,
help_text=_('With this option, people will be able to check in even if the '
'order has not been paid.'))
addon_match = models.BooleanField(
verbose_name=_('Allow checking in add-on tickets by scanning the main ticket'),
default=False,
help_text=_('A scan will only be possible if the check-in list is configured such that there is always exactly '
'one matching add-on ticket. Ambiguous scans will be rejected..')
)
gates = models.ManyToManyField(
'Gate', verbose_name=_("Gates"), blank=True,
help_text=_("Does not have any effect for the validation of tickets, only for the automatic configuration of "
@@ -258,6 +264,7 @@ class Checkin(models.Model):
REASON_REVOKED = 'revoked'
REASON_INCOMPLETE = 'incomplete'
REASON_ALREADY_REDEEMED = 'already_redeemed'
REASON_AMBIGUOUS = 'ambiguous'
REASON_ERROR = 'error'
REASONS = (
(REASON_CANCELED, _('Order canceled')),
@@ -268,6 +275,7 @@ class Checkin(models.Model):
(REASON_INCOMPLETE, _('Information required')),
(REASON_ALREADY_REDEEMED, _('Ticket already used')),
(REASON_PRODUCT, _('Ticket type not allowed here')),
(REASON_AMBIGUOUS, _('Ticket code is ambiguous on list')),
(REASON_ERROR, _('Server error')),
)

View File

@@ -22,9 +22,10 @@
from datetime import datetime, timedelta
from django import forms
from django.core.exceptions import ValidationError
from django.urls import reverse
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import pgettext_lazy
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
@@ -109,6 +110,7 @@ class CheckinListForm(forms.ModelForm):
'rules',
'gates',
'exit_all_at',
'addon_match',
]
widgets = {
'limit_products': forms.CheckboxSelectMultiple(attrs={
@@ -130,6 +132,12 @@ class CheckinListForm(forms.ModelForm):
def clean(self):
d = super().clean()
d['rules'] = CheckinList.validate_rules(d.get('rules'))
if d.get('addon_match') and d.get('all_products'):
raise ValidationError(_('If you allow checking in add-on tickets by scanning the main ticket, you must '
'select a specific set of products for this check-in list, only including the '
'possible add-on products.'))
return d

View File

@@ -55,6 +55,7 @@
{% bootstrap_field form.allow_multiple_entries layout="control" %}
{% bootstrap_field form.allow_entry_after_exit layout="control" %}
{% bootstrap_field form.addon_match layout="control" %}
{% bootstrap_field form.exit_all_at layout="control" %}
{% bootstrap_field form.auto_checkin_sales_channels layout="control" %}
{% if form.gates %}

View File

@@ -59,6 +59,7 @@ window.vapp = new Vue({
'result.rules': gettext('Entry not allowed'),
'result.revoked': gettext('Ticket code revoked/changed'),
'result.canceled': gettext('Order canceled'),
'result.ambiguous': gettext('Ticket code is ambiguous on list'),
'status.checkin': gettext('Checked-in Tickets'),
'status.position': gettext('Valid Tickets'),
'status.inside': gettext('Currently inside'),

View File

@@ -70,7 +70,7 @@ def order(event, item, other_item, taxrule):
total=46, locale='en'
)
InvoiceAddress.objects.create(order=o, company="Sample company", country=Country('NZ'))
OrderPosition.objects.create(
op1 = OrderPosition.objects.create(
order=o,
positionid=1,
item=item,
@@ -90,6 +90,16 @@ def order(event, item, other_item, taxrule):
secret="sf4HZG73fU6kwddgjg2QOusFbYZwVKpK",
pseudonymization_id="BACDEFGHKL",
)
OrderPosition.objects.create(
order=o,
positionid=3,
item=other_item,
addon_to=op1,
variation=None,
price=Decimal("0"),
secret="3u4ez6vrrbgb3wvezxhq446p548dt2wn",
pseudonymization_id="FOOBAR12345",
)
return o
@@ -157,6 +167,38 @@ TEST_ORDERPOSITION2_RES = {
"pseudonymization_id": "BACDEFGHKL",
}
TEST_ORDERPOSITION3_RES = {
"id": 3,
"require_attention": False,
"order__status": "p",
"order": "FOO",
"positionid": 3,
"item": 1,
"variation": None,
"price": "0.00",
"attendee_name": "Peter",
"attendee_name_parts": {'full_name': "Peter"},
"attendee_email": None,
"voucher": None,
"tax_rate": "0.00",
"tax_value": "0.00",
"tax_rule": None,
"secret": "3u4ez6vrrbgb3wvezxhq446p548dt2wn",
"addon_to": None,
"checkins": [],
"downloads": [],
"answers": [],
"seat": None,
"company": None,
"street": None,
"zipcode": None,
"city": None,
"country": None,
"state": None,
"subevent": None,
"pseudonymization_id": "FOOBAR12345",
}
TEST_LIST_RES = {
"name": "Default",
"all_products": False,
@@ -168,6 +210,7 @@ TEST_LIST_RES = {
"allow_entry_after_exit": True,
"subevent": None,
"exit_all_at": None,
"addon_match": False,
"rules": {}
}
@@ -396,27 +439,31 @@ def test_list_update(token_client, organizer, event, clist):
def test_list_all_items_positions(token_client, organizer, event, clist, clist_all, item, other_item, order):
with scopes_disabled():
p1 = dict(TEST_ORDERPOSITION1_RES)
p1["id"] = order.positions.first().pk
p1["id"] = order.positions.get(positionid=1).pk
p1["item"] = item.pk
p2 = dict(TEST_ORDERPOSITION2_RES)
p2["id"] = order.positions.last().pk
p2["id"] = order.positions.get(positionid=2).pk
p2["item"] = other_item.pk
p3 = dict(TEST_ORDERPOSITION3_RES)
p3["id"] = order.positions.get(positionid=3).pk
p3["item"] = other_item.pk
p3["addon_to"] = p1["id"]
# All items
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format(
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p1, p2] == resp.data['results']
assert [p1, p2, p3] == resp.data['results']
# Check-ins on other list ignored
with scopes_disabled():
order.positions.first().checkins.create(list=clist)
c = order.positions.get(positionid=1).checkins.create(list=clist)
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format(
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p1, p2] == resp.data['results']
assert [p1, p2, p3] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?has_checkin=1'.format(
organizer.slug, event.slug, clist_all.pk
))
@@ -425,7 +472,7 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a
# Only checked in
with scopes_disabled():
c = order.positions.first().checkins.create(list=clist_all)
c = order.positions.get(positionid=1).checkins.create(list=clist_all)
p1['checkins'] = [
{
'id': c.pk,
@@ -448,7 +495,7 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p2] == resp.data['results']
assert [p2, p3] == resp.data['results']
# Order by checkin
resp = token_client.get(
@@ -456,18 +503,18 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p1, p2] == resp.data['results']
assert resp.data['results'][0] == p1
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=last_checked_in'.format(
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p2, p1] == resp.data['results']
assert resp.data['results'][-1] == p1
# Order by checkin date
time.sleep(1)
with scopes_disabled():
c = order.positions.last().checkins.create(list=clist_all)
c = order.positions.get(positionid=2).checkins.create(list=clist_all)
p2['checkins'] = [
{
'id': c.pk,
@@ -480,23 +527,23 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a
}
]
resp = token_client.get(
'/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=-last_checked_in'.format(
'/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=-last_checked_in,positionid'.format(
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p2, p1] == resp.data['results']
assert [p2, p1, p3] == resp.data['results']
# Order by attendee_name
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=-attendee_name'.format(
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=-attendee_name,positionid'.format(
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p1, p2] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=attendee_name'.format(
assert [p1, p3, p2] == resp.data['results']
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=attendee_name,positionid'.format(
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p2, p1] == resp.data['results']
assert [p2, p1, p3] == resp.data['results']
# Paid only
order.status = Order.STATUS_PENDING
@@ -513,32 +560,41 @@ def test_list_all_items_positions(token_client, organizer, event, clist, clist_a
assert resp.status_code == 200
p1['order__status'] = 'n'
p2['order__status'] = 'n'
assert [p2, p1] == resp.data['results']
p3['order__status'] = 'n'
assert [p2, p1, p3] == resp.data['results']
@pytest.mark.django_db
def test_list_all_items_positions_by_subevent(token_client, organizer, event, clist, clist_all, item, other_item, order, subevent):
with scopes_disabled():
se2 = event.subevents.create(name="Foobar", date_from=datetime.datetime(2017, 12, 27, 10, 0, 0, tzinfo=UTC))
pfirst = order.positions.first()
pfirst = order.positions.get(positionid=1)
pfirst.subevent = se2
pfirst.save()
p1 = dict(TEST_ORDERPOSITION1_RES)
p1["id"] = pfirst.pk
p1["subevent"] = se2.pk
p1["item"] = item.pk
plast = order.positions.last()
plast.subevent = subevent
plast.save()
psecond = order.positions.get(positionid=2)
psecond.subevent = subevent
psecond.save()
p2 = dict(TEST_ORDERPOSITION2_RES)
p2["id"] = plast.pk
p2["id"] = psecond.pk
p2["item"] = other_item.pk
p2["subevent"] = subevent.pk
pthird = order.positions.get(positionid=3)
pthird.subevent = se2
pthird.save()
p3 = dict(TEST_ORDERPOSITION3_RES)
p3["id"] = pthird.pk
p3["addon_to"] = pfirst.pk
p3["item"] = other_item.pk
p3["subevent"] = se2.pk
resp = token_client.get('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/?ordering=positionid'.format(
organizer.slug, event.slug, clist_all.pk
))
assert resp.status_code == 200
assert [p1, p2] == resp.data['results']
assert [p1, p2, p3] == resp.data['results']
clist_all.subevent = subevent
clist_all.save()
@@ -593,7 +649,7 @@ def test_status(token_client, organizer, event, clist_all, item, other_item, ord
))
assert resp.status_code == 200
assert resp.data['checkin_count'] == 1
assert resp.data['position_count'] == 2
assert resp.data['position_count'] == 3
assert resp.data['inside_count'] == 1
assert resp.data['items'] == [
{
@@ -622,7 +678,7 @@ def test_status(token_client, organizer, event, clist_all, item, other_item, ord
'id': other_item.pk,
'checkin_count': 0,
'admission': False,
'position_count': 1,
'position_count': 2,
'variations': []
}
]
@@ -1185,12 +1241,14 @@ def test_redeem_unknown_revoked_force(token_client, organizer, clist, event, ord
assert resp.status_code == 201
assert resp.data["status"] == "ok"
with scopes_disabled():
assert Checkin.objects.last().forced
assert Checkin.objects.last().force_sent
ci = Checkin.objects.last()
assert ci.forced
assert ci.force_sent
assert ci.position == p
@pytest.mark.django_db
def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clist, event, order):
def test_redeem_unknown_legacy_device_bug(device, device_client, organizer, clist, event):
device.software_brand = "pretixSCAN"
device.software_version = "1.11.1"
device.save()
@@ -1232,7 +1290,6 @@ def test_redeem_by_id_not_allowed_if_pretixscan(device, device_client, organizer
), {
'force': True
}, format='json')
print(resp.data)
assert resp.status_code == 404
resp = device_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, p.secret
@@ -1258,3 +1315,82 @@ def test_redeem_by_id_not_allowed_if_untrusted(device, device_client, organizer,
'force': True
}, format='json')
assert resp.status_code == 201
@pytest.mark.django_db
def test_redeem_addon_if_match_disabled(token_client, organizer, clist, other_item, event, order):
with scopes_disabled():
clist.all_products = False
clist.save()
clist.limit_products.set([other_item])
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'z3fsn8jyufm5kpk768q69gkbyr5f4h6w'
), {
}, format='json')
assert resp.status_code == 400
assert resp.data["status"] == "error"
assert resp.data["reason"] == "product"
with scopes_disabled():
assert not Checkin.objects.last()
@pytest.mark.django_db
def test_redeem_addon_if_match_enabled(token_client, organizer, clist, other_item, event, order):
with scopes_disabled():
clist.all_products = False
clist.addon_match = True
clist.save()
clist.limit_products.set([other_item])
p = order.positions.first().addons.all().first()
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'z3fsn8jyufm5kpk768q69gkbyr5f4h6w'
), {
}, format='json')
assert resp.status_code == 201
assert resp.data['status'] == 'ok'
assert resp.data['position']['attendee_name'] == 'Peter' # test propagation of names
assert resp.data['position']['item'] == other_item.pk
with scopes_disabled():
ci = Checkin.objects.last()
assert ci.position == p
@pytest.mark.django_db
def test_redeem_addon_if_match_ambiguous(token_client, organizer, clist, item, other_item, event, order):
with scopes_disabled():
clist.all_products = False
clist.addon_match = True
clist.save()
clist.limit_products.set([item, other_item])
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
organizer.slug, event.slug, clist.pk, 'z3fsn8jyufm5kpk768q69gkbyr5f4h6w'
), {
}, format='json')
assert resp.status_code == 400
assert resp.data["status"] == "error"
assert resp.data["reason"] == "ambiguous"
with scopes_disabled():
assert not Checkin.objects.last()
@pytest.mark.django_db
def test_redeem_addon_if_match_and_revoked_force(token_client, organizer, clist, other_item, event, order):
with scopes_disabled():
event.revoked_secrets.create(position=order.positions.get(positionid=1), secret='revoked_secret')
clist.all_products = False
clist.addon_match = True
clist.save()
clist.limit_products.set([other_item])
p = order.positions.first().addons.all().first()
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():
ci = Checkin.objects.last()
assert ci.forced
assert ci.force_sent
assert ci.position == p