forked from CGM_Public/pretix_original
Add check-in capabilities to official RESTful API (#884)
* Add check-in capabilities to official RESTful API * Add deprecation note
This commit is contained in:
@@ -330,6 +330,8 @@ Order position endpoints
|
||||
``order__status__in``, ``subevent__in``, ``addon_to__in``, and ``search``. The search for attendee names and order
|
||||
codes is now case-insensitive.
|
||||
|
||||
The ``.../redeem/`` endpoint has been added.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
|
||||
|
||||
Returns a list of all order positions within a given event. The result is the same as
|
||||
@@ -427,7 +429,7 @@ Order position endpoints
|
||||
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
|
||||
:statuscode 404: The requested check-in list does not exist.
|
||||
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)
|
||||
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/
|
||||
|
||||
Returns information on one order position, identified by its internal ID.
|
||||
The result format is the same as the :ref:`order-position-resource`, with one important difference: the
|
||||
@@ -437,7 +439,7 @@ Order position endpoints
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/ HTTP/1.1
|
||||
GET /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/23442/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
@@ -494,3 +496,127 @@ Order position endpoints
|
||||
: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 order position or check-in list does not exist.
|
||||
|
||||
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/(id)/redeem/
|
||||
|
||||
Tries to redeem an order position, identified by its internal ID, i.e. checks the attendee in. This endpoint
|
||||
accepts a number of optional requests in the body.
|
||||
|
||||
:<json boolean questions_supported: When this parameter is set to ``true``, handling of questions is supported. If
|
||||
you do not implement question handling in your user interface, you **must**
|
||||
set this to ``false``. In that case, questions will just be ignored. Defaults
|
||||
to ``true``.
|
||||
:<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
|
||||
questions that have not been filled. Defaults to ``false``.
|
||||
:<json boolean ignore_unpaid: Specifies that the check-in should succeed even if the order is in pending state.
|
||||
Defaults to ``false``.
|
||||
:<json string nonce: You can set this parameter to a unique random value to identify this check-in. If you're sending
|
||||
this request twice with the same nonce, the second request will also succeed but will always
|
||||
create only one check-in object even when the previous request was successful as well. This
|
||||
allows for a certain level of idempotency and enables you to re-try after a connection failure.
|
||||
:<json object answers: If questions are supported/required, you may/must supply a mapping of question IDs to their
|
||||
respective answers. The answers should always be strings. In case of (multiple-)choice-type
|
||||
answers, the string should contain the (comma-separated) IDs of the selected options.
|
||||
|
||||
**Example request**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/1/positions/234/redeem/ HTTP/1.1
|
||||
Host: pretix.eu
|
||||
Accept: application/json, text/javascript
|
||||
|
||||
{
|
||||
"force": false,
|
||||
"ignore_unpaid": false,
|
||||
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
|
||||
"datetime": null,
|
||||
"questions_supported": true,
|
||||
"answers": {
|
||||
"4": "XS"
|
||||
}
|
||||
}
|
||||
|
||||
**Example successful response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 201 Created
|
||||
Vary: Accept
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"status": "ok"
|
||||
}
|
||||
|
||||
**Example response with required questions**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 400 Bad Request
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "incomplete"
|
||||
"questions": [
|
||||
{
|
||||
"id": 1,
|
||||
"question": {"en": "T-Shirt size"},
|
||||
"type": "C",
|
||||
"required": false,
|
||||
"items": [1, 2],
|
||||
"position": 1,
|
||||
"identifier": "WY3TP9SL",
|
||||
"ask_during_checkin": true,
|
||||
"options": [
|
||||
{
|
||||
"id": 1,
|
||||
"identifier": "LVETRWVU",
|
||||
"position": 0,
|
||||
"answer": {"en": "S"}
|
||||
},
|
||||
{
|
||||
"id": 2,
|
||||
"identifier": "DFEMJWMJ",
|
||||
"position": 1,
|
||||
"answer": {"en": "M"}
|
||||
},
|
||||
{
|
||||
"id": 3,
|
||||
"identifier": "W9AH7RDE",
|
||||
"position": 2,
|
||||
"answer": {"en": "L"}
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
**Example error response**:
|
||||
|
||||
.. sourcecode:: http
|
||||
|
||||
HTTP/1.1 200 OK
|
||||
Content-Type: text/json
|
||||
|
||||
{
|
||||
"status": "error",
|
||||
"reason": "unpaid",
|
||||
}
|
||||
|
||||
Possible error reasons:
|
||||
|
||||
* ``unpaid`` - Ticket is not paid for or has been refunded
|
||||
* ``already_redeemed`` - Ticket already has been redeemed
|
||||
* ``product`` - Tickets with this product may not be scanned at this device
|
||||
|
||||
:param organizer: The ``slug`` field of the organizer to fetch
|
||||
:param event: The ``slug`` field of the event to fetch
|
||||
:param list: The ID of the check-in list to look for
|
||||
:param id: The ``id`` field of the order position to fetch
|
||||
:statuscode 201: 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 order position or check-in list does not exist.
|
||||
|
||||
@@ -4,10 +4,10 @@ pretixdroid HTTP API
|
||||
The pretixdroid plugin provides a HTTP API that the `pretixdroid Android app`_
|
||||
uses to communicate with the pretix server.
|
||||
|
||||
.. warning:: This API is intended **only** to serve the pretixdroid Android app. There are no backwards compatibility
|
||||
guarantees on this API. We will not add features that are not required for the Android App. There is a
|
||||
general-purpose :ref:`rest-api` that not yet provides all features that this API provides, but will do
|
||||
so in the future.
|
||||
.. warning:: This API is **DEPRECATED** and will probably go away soon. It is used **only** to serve the pretixdroid
|
||||
Android app. There are no backwards compatibility guarantees on this API. We will not add features that
|
||||
are not required for the Android App. There is a general-purpose :ref:`rest-api` that provides all
|
||||
features that you need to check in.
|
||||
|
||||
.. versionchanged:: 1.12
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ gettext
|
||||
gunicorn
|
||||
hardcoded
|
||||
hostname
|
||||
idempotency
|
||||
inofficial
|
||||
invalidations
|
||||
iterable
|
||||
|
||||
@@ -1,18 +1,25 @@
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
|
||||
from django.db.models.functions import Coalesce
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.timezone import now
|
||||
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
|
||||
from rest_framework import viewsets
|
||||
from rest_framework.decorators import detail_route
|
||||
from rest_framework.fields import DateTimeField
|
||||
from rest_framework.response import Response
|
||||
|
||||
from pretix.api.serializers.checkin import CheckinListSerializer
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.api.serializers.order import OrderPositionSerializer
|
||||
from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.models import Checkin, CheckinList, Order, OrderPosition
|
||||
from pretix.base.models.organizer import TeamAPIToken
|
||||
from pretix.base.services.checkin import (
|
||||
CheckInError, RequiredQuestionsError, perform_checkin,
|
||||
)
|
||||
from pretix.helpers.database import FixedOrderBy
|
||||
|
||||
|
||||
@@ -201,3 +208,53 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
|
||||
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
|
||||
|
||||
return qs
|
||||
|
||||
@detail_route(methods=['POST'])
|
||||
def redeem(self, *args, **kwargs):
|
||||
force = bool(self.request.data.get('force', False))
|
||||
ignore_unpaid = bool(self.request.data.get('ignore_unpaid', False))
|
||||
nonce = self.request.data.get('nonce')
|
||||
op = self.get_object()
|
||||
|
||||
if 'datetime' in self.request.data:
|
||||
dt = DateTimeField().to_internal_value(self.request.data.get('datetime'))
|
||||
else:
|
||||
dt = now()
|
||||
|
||||
given_answers = {}
|
||||
if 'answers' in self.request.data:
|
||||
aws = self.request.data.get('answers')
|
||||
for q in op.item.questions.filter(ask_during_checkin=True):
|
||||
if str(q.pk) in aws:
|
||||
try:
|
||||
given_answers[q] = q.clean_answer(aws[str(q.pk)])
|
||||
except ValidationError:
|
||||
pass
|
||||
|
||||
try:
|
||||
perform_checkin(
|
||||
op=op,
|
||||
clist=self.checkinlist,
|
||||
given_answers=given_answers,
|
||||
force=force,
|
||||
ignore_unpaid=ignore_unpaid,
|
||||
nonce=nonce,
|
||||
datetime=dt,
|
||||
questions_supported=self.request.data.get('questions_supported', True)
|
||||
)
|
||||
except RequiredQuestionsError as e:
|
||||
return Response({
|
||||
'status': 'incomplete',
|
||||
'questions': [
|
||||
QuestionSerializer(q).data for q in e.questions
|
||||
]
|
||||
}, status=400)
|
||||
except CheckInError as e:
|
||||
return Response({
|
||||
'status': 'error',
|
||||
'reason': e.code
|
||||
}, status=400)
|
||||
else:
|
||||
return Response({
|
||||
'status': 'ok',
|
||||
}, status=201)
|
||||
|
||||
@@ -4,9 +4,12 @@ from decimal import Decimal
|
||||
from unittest import mock
|
||||
|
||||
import pytest
|
||||
from django.utils.timezone import now
|
||||
from django_countries.fields import Country
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from pytz import UTC
|
||||
|
||||
from pretix.api.serializers.item import QuestionSerializer
|
||||
from pretix.base.models import (
|
||||
Checkin, CheckinList, InvoiceAddress, Order, OrderPosition,
|
||||
)
|
||||
@@ -436,3 +439,219 @@ def test_status(token_client, organizer, event, clist_all, item, other_item, ord
|
||||
'variations': []
|
||||
}
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_custom_datetime(token_client, organizer, clist, event, order):
|
||||
dt = now() - datetime.timedelta(days=1)
|
||||
dt = dt.replace(microsecond=0)
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {
|
||||
'datetime': dt.isoformat()
|
||||
}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
assert Checkin.objects.last().datetime == dt
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_only_once(token_client, organizer, clist, event, order):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'error'
|
||||
assert resp.data['reason'] == 'already_redeemed'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_reupload_same_nonce(token_client, organizer, clist, event, order):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'nonce': 'foobar'}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'nonce': 'foobar'}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_multiple_different_list(token_client, organizer, clist, clist_all, event, order):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'nonce': 'foobar'}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist_all.pk, order.positions.first().pk
|
||||
), {'nonce': 'baz'}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_forced_multiple(token_client, organizer, clist, event, order):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'force': True}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_require_paid(token_client, organizer, clist, event, order):
|
||||
order.status = Order.STATUS_PENDING
|
||||
order.save()
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 404
|
||||
|
||||
clist.include_pending = True
|
||||
clist.save()
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'error'
|
||||
assert resp.data['reason'] == 'unpaid'
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'ignore_unpaid': True}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def question(event, item):
|
||||
q = event.questions.create(question=LazyI18nString('Size'), type='C', required=True, ask_during_checkin=True)
|
||||
a1 = q.options.create(answer=LazyI18nString("M"))
|
||||
a2 = q.options.create(answer=LazyI18nString("L"))
|
||||
q.items.add(item)
|
||||
return q, a1, a2
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_question_number(token_client, organizer, clist, event, order, question):
|
||||
question[0].options.all().delete()
|
||||
question[0].type = 'N'
|
||||
question[0].save()
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'incomplete'
|
||||
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'answers': {question[0].pk: "3.24"}}, format='json')
|
||||
print(resp.data)
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
assert order.positions.first().answers.get(question=question[0]).answer == '3.24'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_question_choice(token_client, organizer, clist, event, order, question):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'incomplete'
|
||||
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'answers': {question[0].pk: str(question[1].pk)}}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
assert order.positions.first().answers.get(question=question[0]).answer == 'M'
|
||||
assert list(order.positions.first().answers.get(question=question[0]).options.all()) == [question[1]]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_question_invalid(token_client, organizer, clist, event, order, question):
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'answers': {question[0].pk: "A"}}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'incomplete'
|
||||
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_question_required(token_client, organizer, clist, event, order, question):
|
||||
question[0].required = True
|
||||
question[0].save()
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'incomplete'
|
||||
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'answers': {question[0].pk: ""}}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'incomplete'
|
||||
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_question_optional(token_client, organizer, clist, event, order, question):
|
||||
question[0].required = False
|
||||
question[0].save()
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'incomplete'
|
||||
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'answers': {question[0].pk: ""}}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_question_multiple_choice(token_client, organizer, clist, event, order, question):
|
||||
question[0].type = 'M'
|
||||
question[0].save()
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {}, format='json')
|
||||
assert resp.status_code == 400
|
||||
assert resp.data['status'] == 'incomplete'
|
||||
assert resp.data['questions'] == [QuestionSerializer(question[0]).data]
|
||||
|
||||
resp = token_client.post('/api/v1/organizers/{}/events/{}/checkinlists/{}/positions/{}/redeem/'.format(
|
||||
organizer.slug, event.slug, clist.pk, order.positions.first().pk
|
||||
), {'answers': {question[0].pk: "{},{}".format(question[1].pk, question[2].pk)}}, format='json')
|
||||
assert resp.status_code == 201
|
||||
assert resp.data['status'] == 'ok'
|
||||
assert order.positions.first().answers.get(question=question[0]).answer == 'M, L'
|
||||
assert set(order.positions.first().answers.get(question=question[0]).options.all()) == {question[1], question[2]}
|
||||
|
||||
Reference in New Issue
Block a user