Questions at check-in time (#745)

Questions at check-in time
This commit is contained in:
Raphael Michel
2018-01-22 22:55:54 +01:00
committed by GitHub
parent 7fb2d0526e
commit d0dfde382c
20 changed files with 754 additions and 47 deletions

View File

@@ -65,7 +65,8 @@ class QuestionSerializer(I18nAwareModelSerializer):
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position')
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin')
class QuotaSerializer(I18nAwareModelSerializer):

View File

@@ -0,0 +1,20 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.8 on 2018-01-15 14:26
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0079_auto_20180115_0855'),
]
operations = [
migrations.AddField(
model_name='question',
name='ask_during_checkin',
field=models.BooleanField(default=False, help_text='Supported by pretixdroid 1.8 and newer or pretixdesk 0.2 and newer.', verbose_name='Ask during check-in instead of during registration'),
),
]

View File

@@ -1,15 +1,18 @@
import sys
import uuid
from datetime import datetime
from decimal import Decimal
from datetime import date, datetime, time
from decimal import Decimal, DecimalException
from typing import Tuple
import dateutil.parser
import pytz
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import F, Func, Q, Sum
from django.utils import formats
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
@@ -557,6 +560,8 @@ class Question(LoggedModel):
items associated with this question.
:type required: bool
:param items: A set of ``Items`` objects that this question should be applied to
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool
"""
TYPE_NUMBER = "N"
TYPE_STRING = "S"
@@ -612,6 +617,12 @@ class Question(LoggedModel):
position = models.IntegerField(
default=0
)
ask_during_checkin = models.BooleanField(
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('This will only work if you handle your check-in with pretixdroid 1.8 or newer or '
'pretixdesk 0.2 or newer.'),
default=False
)
class Meta:
verbose_name = _("Question")
@@ -638,6 +649,64 @@ class Question(LoggedModel):
def __lt__(self, other) -> bool:
return self.sortkey < other.sortkey
def clean_answer(self, answer):
if self.required:
if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)):
raise ValidationError(_('An answer to this question is required to proceed.'))
if not answer:
if self.type == Question.TYPE_BOOLEAN:
return False
return None
if self.type == Question.TYPE_CHOICE:
try:
return self.options.get(pk=answer)
except:
raise ValidationError(_('Invalid option selected.'))
elif self.type == Question.TYPE_CHOICE_MULTIPLE:
try:
if isinstance(answer, str):
return list(self.options.filter(pk__in=answer.split(",")))
else:
return list(self.options.filter(pk__in=answer))
except:
raise ValidationError(_('Invalid option selected.'))
elif self.type == Question.TYPE_BOOLEAN:
return answer in ('true', 'True', True)
elif self.type == Question.TYPE_NUMBER:
answer = formats.sanitize_separators(answer)
answer = str(answer).strip()
try:
return Decimal(answer)
except DecimalException:
raise ValidationError(_('Invalid number input.'))
elif self.type == Question.TYPE_DATE:
if isinstance(answer, date):
return answer
try:
return dateutil.parser.parse(answer).date()
except:
raise ValidationError(_('Invalid date input.'))
elif self.type == Question.TYPE_TIME:
if isinstance(answer, time):
return answer
try:
return dateutil.parser.parse(answer).time()
except:
raise ValidationError(_('Invalid time input.'))
elif self.type == Question.TYPE_DATETIME and answer:
if isinstance(answer, datetime):
return answer
try:
dt = dateutil.parser.parse(answer)
if is_naive(dt):
dt = make_aware(dt, pytz.timezone(self.event.settings.timezone))
return dt
except:
raise ValidationError(_('Invalid datetime input.'))
return answer
class QuestionOption(models.Model):
question = models.ForeignKey('Question', related_name='options')

View File

@@ -506,16 +506,16 @@ class QuestionAnswer(models.Model):
return str(_("No"))
elif self.question.type == Question.TYPE_FILE:
return str(_("<file>"))
elif self.question.type == Question.TYPE_DATETIME:
elif self.question.type == Question.TYPE_DATETIME and self.answer:
d = dateutil.parser.parse(self.answer)
if self.orderposition:
tz = pytz.timezone(self.orderposition.order.event.settings.timezone)
d = d.astimezone(tz)
return date_format(d, "SHORT_DATETIME_FORMAT")
elif self.question.type == Question.TYPE_DATE:
elif self.question.type == Question.TYPE_DATE and self.answer:
d = dateutil.parser.parse(self.answer)
return date_format(d, "SHORT_DATE_FORMAT")
elif self.question.type == Question.TYPE_TIME:
elif self.question.type == Question.TYPE_TIME and self.answer:
d = dateutil.parser.parse(self.answer)
return date_format(d, "TIME_FORMAT")
else:
@@ -605,7 +605,7 @@ class AbstractPosition(models.Model):
else:
return {}
def cache_answers(self):
def cache_answers(self, all=True):
"""
Creates two properties on the object.
(1) answ: a dictionary of question.id → answer string
@@ -618,7 +618,13 @@ class AbstractPosition(models.Model):
# We need to clone our question objects, otherwise we will override the cached
# answers of other items in the same cart if the question objects have been
# selected via prefetch_related
self.questions = list(copy.copy(q) for q in self.item.questions.all())
if not all:
if hasattr(self.item, 'questions_to_ask'):
self.questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else:
self.questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False))
else:
self.questions = list(copy.copy(q) for q in self.item.questions.all())
for q in self.questions:
if q.id in self.answ:
q.answer = self.answ[q.id]

View File

@@ -45,6 +45,7 @@ class QuestionForm(I18nModelForm):
'help_text',
'type',
'required',
'ask_during_checkin',
'items'
]
widgets = {

View File

@@ -23,6 +23,7 @@
{% bootstrap_field form.question layout="control" %}
{% bootstrap_field form.help_text layout="control" %}
{% bootstrap_field form.type layout="control" %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% bootstrap_field form.required layout="control" %}
</fieldset>
<fieldset>

View File

@@ -42,7 +42,14 @@
<td><strong><a href="
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=q.id %}">{{ q.question }}</a></strong>
</td>
<td>{{ q.get_type_display }}</td>
<td>
{{ q.get_type_display }}
{% if q.required %}
<span class="fa fa-exclamation-circle text-muted"
data-toggle="tooltip" title="{% trans "Required question" %}">
</span>
{% endif %}
</td>
<td>
<ul>
{% for item in q.items.all %}

View File

@@ -213,7 +213,15 @@
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% for q in line.questions %}
<dt>{{ q.question }}</dt>
<dt>
{{ q.question }}
{% if q.ask_during_checkin %}
<span class="fa fa-qrcode text-muted"
data-toggle="tooltip"
title="{% trans "This question will be asked during check-in." %}"
></span>
{% endif %}
</dt>
<dd>
{% if q.answer %}
{% if q.answer.file %}

View File

@@ -4,8 +4,9 @@ import urllib.parse
import dateutil.parser
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import Count, Max, OuterRef, Q, Subquery
from django.db.models import Count, Max, OuterRef, Prefetch, Q, Subquery
from django.http import (
HttpResponseForbidden, HttpResponseNotFound, JsonResponse,
)
@@ -18,7 +19,9 @@ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.csrf import csrf_exempt
from django.views.generic import TemplateView, View
from pretix.base.models import Checkin, Event, Order, OrderPosition
from pretix.base.models import (
Checkin, Event, Order, OrderPosition, Question, QuestionOption,
)
from pretix.base.models.event import SubEvent
from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.helpers.urls import build_absolute_uri
@@ -153,6 +156,40 @@ class ApiView(View):
class ApiRedeemView(ApiView):
def _save_answers(self, op, answers, given_answers):
for q, a in given_answers.items():
if not a:
if q in answers:
answers[q].delete()
else:
continue
if isinstance(a, QuestionOption):
if q in answers:
qa = answers[q]
qa.answer = str(a.answer)
qa.save()
qa.options.clear()
else:
qa = op.answers.create(question=q, answer=str(a.answer))
qa.options.add(a)
elif isinstance(a, list):
if q in answers:
qa = answers[q]
qa.answer = ", ".join([str(o) for o in a])
qa.save()
qa.options.clear()
else:
qa = op.answers.create(question=q, answer=", ".join([str(o) for o in a]))
qa.options.add(*a)
else:
if q in answers:
qa = answers[q]
qa.answer = str(a)
qa.save()
else:
op.answers.create(question=q, answer=str(a))
def post(self, request, **kwargs):
secret = request.POST.get('secret', '!INVALID!')
force = request.POST.get('force', 'false') in ('true', 'True')
@@ -169,15 +206,46 @@ class ApiRedeemView(ApiView):
try:
with transaction.atomic():
created = False
op = OrderPosition.objects.select_related('item', 'variation', 'order', 'addon_to').get(
op = OrderPosition.objects.select_related(
'item', 'variation', 'order', 'addon_to'
).prefetch_related(
'item__questions',
Prefetch(
'item__questions',
queryset=Question.objects.filter(ask_during_checkin=True),
to_attr='checkin_questions'
),
'answers'
).get(
order__event=self.event, secret=secret, subevent=self.subevent
)
answers = {a.question: a for a in op.answers.all()}
require_answers = []
given_answers = {}
for q in op.item.checkin_questions:
if 'answer_{}'.format(q.pk) in request.POST:
try:
given_answers[q] = q.clean_answer(request.POST.get('answer_{}'.format(q.pk)))
continue
except ValidationError:
pass
if q in answers:
continue
require_answers.append(serialize_question(q))
self._save_answers(op, answers, given_answers)
if not self.config.list.all_products and op.item_id not in [i.pk for i in self.config.list.limit_products.all()]:
response['status'] = 'error'
response['reason'] = 'product'
if not self.config.all_items and op.item_id not in [i.pk for i in self.config.items.all()]:
elif not self.config.all_items and op.item_id not in [i.pk for i in self.config.items.all()]:
response['status'] = 'error'
response['reason'] = 'product'
elif require_answers and not force and request.POST.get('questions_supported'):
response['status'] = 'incomplete'
response['questions'] = require_answers
elif op.order.status == Order.STATUS_PAID or force:
ci, created = Checkin.objects.get_or_create(position=op, list=self.config.list, defaults={
'datetime': dt,
@@ -223,6 +291,25 @@ class ApiRedeemView(ApiView):
return JsonResponse(response)
def serialize_question(q, items=False):
d = {
'id': q.pk,
'type': q.type,
'question': str(q.question),
'required': q.required,
'position': q.position,
'options': [
{
'id': o.pk,
'answer': str(o.answer)
} for o in q.options.all()
] if q.type in ('C', 'M') else []
}
if items:
d['items'] = [i.pk for i in q.items.all()]
return d
def serialize_op(op, redeemed):
name = op.attendee_name
if not name and op.addon_to:
@@ -319,6 +406,9 @@ class ApiDownloadView(ApiView):
qs = qs.filter(item__in=self.config.items.all())
response['results'] = [serialize_op(op, bool(op.last_checked_in)) for op in qs]
questions = self.event.questions.filter(ask_during_checkin=True).prefetch_related('items', 'options')
response['questions'] = [serialize_question(q, items=True) for q in questions]
return JsonResponse(response)

View File

@@ -374,9 +374,9 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
for cp in self._positions_for_questions:
answ = {
aw.question_id: aw.answer for aw in cp.answers.all()
aw.question_id: aw.answer for aw in cp.answerlist
}
for q in cp.item.questions.all():
for q in cp.item.questions_to_ask:
if q.required and q.id not in answ:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))

View File

@@ -212,7 +212,7 @@ class QuestionsForm(forms.Form):
orderpos = self.orderpos = kwargs.pop('orderpos', None)
pos = cartpos or orderpos
item = pos.item
questions = list(item.questions.all())
questions = pos.item.questions_to_ask
event = kwargs.pop('event')
super().__init__(*args, **kwargs)
@@ -232,11 +232,7 @@ class QuestionsForm(forms.Form):
for q in questions:
# Do we already have an answer? Provide it as the initial value
answers = [
a for a
in (cartpos.answers.all() if cartpos else orderpos.answers.all())
if a.question_id == q.id
]
answers = [a for a in pos.answerlist if a.question_id == q.id]
if answers:
initial = answers[0]
else:
@@ -282,7 +278,7 @@ class QuestionsForm(forms.Form):
)
elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField(
queryset=q.options.all(),
queryset=q.options,
label=q.question, required=q.required,
help_text=q.help_text,
widget=forms.Select,
@@ -291,7 +287,7 @@ class QuestionsForm(forms.Form):
)
elif q.type == Question.TYPE_CHOICE_MULTIPLE:
field = forms.ModelMultipleChoiceField(
queryset=q.options.all(),
queryset=q.options,
label=q.question, required=q.required,
help_text=q.help_text,
widget=forms.CheckboxSelectMultiple,

View File

@@ -114,7 +114,7 @@ class CartMixin:
group.has_questions = answers and k[0] != ""
group.tax_rule = group.item.tax_rule
if answers:
group.cache_answers()
group.cache_answers(all=False)
group.additional_answers = pos_additional_fields.get(group.pk)
positions.append(group)
@@ -155,6 +155,16 @@ class CartMixin:
}
def cart_exists(request):
from pretix.presale.views.cart import get_or_create_cart_id
if not hasattr(request, '_cart_cache'):
return CartPosition.objects.filter(
cart_id=get_or_create_cart_id(request), event=request.event
).exists()
return bool(request._cart_cache)
def get_cart(request):
from pretix.presale.views.cart import get_or_create_cart_id
@@ -166,8 +176,6 @@ def get_cart(request):
).select_related(
'item', 'variation', 'subevent', 'subevent__event', 'subevent__event__organizer',
'item__tax_rule'
).prefetch_related(
'item__questions', 'answers'
)
return request._cart_cache

View File

@@ -10,7 +10,8 @@ from pretix.base.signals import validate_cart
from pretix.multidomain.urlreverse import eventreverse
from pretix.presale.checkoutflow import get_checkout_flow
from pretix.presale.views import (
allow_frame_if_namespaced, get_cart, iframe_entry_view_wrapper,
allow_frame_if_namespaced, cart_exists, get_cart,
iframe_entry_view_wrapper,
)
@@ -27,7 +28,7 @@ class CheckoutView(View):
def dispatch(self, request, *args, **kwargs):
self.request = request
if not get_cart(request) and "async_id" not in request.GET:
if not cart_exists(request) and "async_id" not in request.GET:
messages.error(request, _("Your cart is empty"))
return redirect(self.get_index_url(self.request))

View File

@@ -4,7 +4,7 @@ from decimal import Decimal
from django.contrib import messages
from django.db import transaction
from django.db.models import Sum
from django.db.models import Prefetch, Sum
from django.http import FileResponse, Http404, JsonResponse
from django.shortcuts import get_object_or_404, redirect, render
from django.utils.decorators import method_decorator
@@ -14,7 +14,9 @@ from django.utils.translation import ugettext_lazy as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.generic import TemplateView, View
from pretix.base.models import CachedTicket, Invoice, Order, OrderPosition
from pretix.base.models import (
CachedTicket, Invoice, Order, OrderPosition, Question, QuestionOption,
)
from pretix.base.models.orders import (
CachedCombinedTicket, InvoiceAddress, OrderFee, QuestionAnswer,
)
@@ -435,7 +437,21 @@ class OrderModify(EventViewMixin, OrderDetailMixin, QuestionsViewMixin, Template
return list(self.order.positions.select_related(
'item', 'variation'
).prefetch_related(
'variation', 'item__questions', 'answers'
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questions',
Question.objects.filter(ask_during_checkin=False).prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
),
to_attr='questions_to_ask')
))
@cached_property

View File

@@ -3,9 +3,12 @@ from collections import OrderedDict
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db.models import Prefetch
from django.utils.functional import cached_property
from pretix.base.models import CartPosition, OrderPosition, QuestionAnswer
from pretix.base.models import (
CartPosition, OrderPosition, Question, QuestionAnswer, QuestionOption,
)
from pretix.presale.forms.checkout import QuestionsForm
from pretix.presale.views import get_cart
@@ -26,7 +29,24 @@ class QuestionsViewMixin:
def _positions_for_questions(self):
cart = get_cart(self.request).select_related(
'addon_to'
).prefetch_related('addons', 'addons__item', 'addons__variation')
).prefetch_related(
'addons', 'addons__item', 'addons__variation',
Prefetch('answers',
QuestionAnswer.objects.prefetch_related('options'),
to_attr='answerlist'),
Prefetch('item__questions',
Question.objects.filter(ask_during_checkin=False).prefetch_related(
Prefetch('options', QuestionOption.objects.prefetch_related(Prefetch(
# This prefetch statement is utter bullshit, but it actually prevents Django from doing
# a lot of queries since ModelChoiceIterator stops trying to be clever once we have
# a prefetch lookup on this query...
'question',
Question.objects.none(),
to_attr='dummy'
)))
),
to_attr='questions_to_ask')
)
return sorted(list(cart), key=self._keyfunc)
@cached_property

View File

@@ -453,6 +453,7 @@ TEST_QUESTION_RES = {
"type": "C",
"required": False,
"items": [],
"ask_during_checkin": False,
"position": 0,
"options": [
{

View File

@@ -5,6 +5,7 @@ from decimal import Decimal
import pytest
import pytz
from dateutil.tz import tzoffset
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.storage import default_storage
@@ -12,6 +13,7 @@ from django.core.files.uploadedfile import SimpleUploadedFile
from django.test import TestCase
from django.utils.timezone import now
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, CartPosition, CheckinList, Event, Item, ItemCategory,
ItemVariation, Order, OrderPosition, Organizer, Question, Quota, User,
@@ -1130,3 +1132,106 @@ class CheckinListTestCase(TestCase):
assert lists[2].checkin_count == 1
assert lists[2].position_count == 2
assert lists[2].percent == 50
@pytest.mark.django_db
@pytest.mark.parametrize("qtype,answer,expected", [
(Question.TYPE_STRING, "a", "a"),
(Question.TYPE_TEXT, "v", "v"),
(Question.TYPE_NUMBER, "3", Decimal("3")),
(Question.TYPE_NUMBER, "2.56", Decimal("2.56")),
(Question.TYPE_NUMBER, 2.45, Decimal("2.45")),
(Question.TYPE_NUMBER, 3, Decimal("3")),
(Question.TYPE_NUMBER, Decimal("4.56"), Decimal("4.56")),
(Question.TYPE_NUMBER, "abc", ValidationError),
(Question.TYPE_BOOLEAN, "True", True),
(Question.TYPE_BOOLEAN, "true", True),
(Question.TYPE_BOOLEAN, "False", False),
(Question.TYPE_BOOLEAN, "false", False),
(Question.TYPE_BOOLEAN, "0", False),
(Question.TYPE_BOOLEAN, "", False),
(Question.TYPE_BOOLEAN, True, True),
(Question.TYPE_BOOLEAN, False, False),
(Question.TYPE_DATE, "2018-01-16", datetime.date(2018, 1, 16)),
(Question.TYPE_DATE, datetime.date(2018, 1, 16), datetime.date(2018, 1, 16)),
(Question.TYPE_DATE, "2018-13-16", ValidationError),
(Question.TYPE_TIME, "15:20", datetime.time(15, 20)),
(Question.TYPE_TIME, datetime.time(15, 20), datetime.time(15, 20)),
(Question.TYPE_TIME, "44:20", ValidationError),
(Question.TYPE_DATETIME, "2018-01-16T15:20:00+01:00",
datetime.datetime(2018, 1, 16, 15, 20, 0, tzinfo=tzoffset(None, 3600))),
(Question.TYPE_DATETIME, "2018-01-16T15:20:00Z",
datetime.datetime(2018, 1, 16, 15, 20, 0, tzinfo=tzoffset(None, 0))),
(Question.TYPE_DATETIME, "2018-01-16T15:20:00",
datetime.datetime(2018, 1, 16, 15, 20, 0, tzinfo=tzoffset(None, 3600))),
(Question.TYPE_DATETIME, "2018-01-16T15:AB:CD", ValidationError),
])
def test_question_answer_validation(qtype, answer, expected):
o = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=o, name='Dummy', slug='dummy',
date_from=now(),
)
event.settings.timezone = 'Europe/Berlin'
q = Question(type=qtype, event=event)
if isinstance(expected, type) and issubclass(expected, Exception):
with pytest.raises(expected):
q.clean_answer(answer)
elif callable(expected):
assert expected(q.clean_answer(answer))
else:
assert q.clean_answer(answer) == expected
@pytest.mark.django_db
def test_question_answer_validation_localized_decimal():
q = Question(type='N')
with language("de"):
assert q.clean_answer("2,56") == Decimal("2.56")
@pytest.mark.django_db
def test_question_answer_validation_choice():
organizer = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
date_from=now(), date_to=now() - timedelta(hours=1),
)
q = Question.objects.create(type='C', event=event, question='Q')
o1 = q.options.create(answer='A')
o2 = q.options.create(answer='B')
q2 = Question.objects.create(type='C', event=event, question='Q2')
o3 = q2.options.create(answer='C')
assert q.clean_answer(str(o1.pk)) == o1
assert q.clean_answer(o1.pk) == o1
assert q.clean_answer(str(o2.pk)) == o2
assert q.clean_answer(o2.pk) == o2
with pytest.raises(ValidationError):
q.clean_answer(str(o2.pk + 1000))
with pytest.raises(ValidationError):
q.clean_answer('FOO')
with pytest.raises(ValidationError):
q.clean_answer(str(o3.pk))
@pytest.mark.django_db
def test_question_answer_validation_multiple_choice():
organizer = Organizer.objects.create(name='Dummy', slug='dummy')
event = Event.objects.create(
organizer=organizer, name='Dummy', slug='dummy',
date_from=now(), date_to=now() - timedelta(hours=1),
)
q = Question.objects.create(type='M', event=event, question='Q')
o1 = q.options.create(answer='A')
o2 = q.options.create(answer='B')
q.options.create(answer='D')
q2 = Question.objects.create(type='M', event=event, question='Q2')
o3 = q2.options.create(answer='C')
assert q.clean_answer("{},{}".format(str(o1.pk), str(o2.pk))) == [o1, o2]
assert q.clean_answer([str(o1.pk), str(o2.pk)]) == [o1, o2]
assert q.clean_answer([str(o1.pk)]) == [o1]
assert q.clean_answer([o1.pk]) == [o1]
assert q.clean_answer([o1.pk, o3.pk]) == [o1]
assert q.clean_answer([o1.pk, o3.pk + 1000]) == [o1]
with pytest.raises(ValidationError):
assert q.clean_answer([o1.pk, 'FOO']) == [o1]

View File

@@ -326,3 +326,233 @@ def test_status(client, env):
'variations': []
}
]
@pytest.fixture
def question(env):
q = env[0].questions.create(question='Size', type='C', required=True, ask_during_checkin=True)
a1 = q.options.create(answer="M")
a2 = q.options.create(answer="L")
q.items.add(env[3].item)
return q, a1, a2
@pytest.mark.django_db
def test_question_number(client, env, question):
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
question[0].options.all().delete()
question[0].type = 'N'
question[0].save()
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234', 'questions_supported': 'true'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['version'] == API_VERSION
assert jdata['status'] == 'incomplete'
assert jdata['questions'] == [
{
'id': question[0].pk,
'type': 'N',
'question': 'Size',
'required': True,
'position': question[0].position,
'options': []
}
]
resp = client.post(
'/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={
'secret': '1234',
'answer_{}'.format(question[0].pk): '3.24',
}
)
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'ok'
assert env[3].answers.get(question=question[0]).answer == '3.24'
@pytest.mark.django_db
def test_question_choice(client, env, question):
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234', 'questions_supported': 'true'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['version'] == API_VERSION
assert jdata['status'] == 'incomplete'
assert jdata['questions'] == [
{
'id': question[0].pk,
'type': 'C',
'question': 'Size',
'required': True,
'position': question[0].position,
'options': [
{
'id': question[1].pk,
'answer': 'M'
},
{
'id': question[2].pk,
'answer': 'L'
}
]
}
]
resp = client.post(
'/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={
'secret': '1234',
'answer_{}'.format(question[0].pk): question[1].pk,
}
)
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'ok'
assert env[3].answers.get(question=question[0]).answer == 'M'
assert list(env[3].answers.get(question=question[0]).options.all()) == [question[1]]
@pytest.mark.django_db
def test_question_invalid(client, env, question):
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234', 'questions_supported': 'true'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['version'] == API_VERSION
assert jdata['status'] == 'incomplete'
assert len(jdata['questions']) == 1
resp = client.post(
'/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={
'secret': '1234', 'questions_supported': 'true',
'answer_{}'.format(question[0].pk): "A",
}
)
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'incomplete'
assert len(jdata['questions']) == 1
@pytest.mark.django_db
def test_question_required(client, env, question):
question[0].required = True
question[0].save()
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234', 'questions_supported': 'true'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['version'] == API_VERSION
assert jdata['status'] == 'incomplete'
assert len(jdata['questions']) == 1
resp = client.post(
'/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={
'secret': '1234', 'questions_supported': 'true',
'answer_{}'.format(question[0].pk): "",
}
)
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'incomplete'
assert len(jdata['questions']) == 1
@pytest.mark.django_db
def test_question_optional(client, env, question):
question[0].required = False
question[0].save()
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234', 'questions_supported': 'true'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['version'] == API_VERSION
assert jdata['status'] == 'incomplete'
assert len(jdata['questions']) == 1
resp = client.post(
'/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={
'secret': '1234', 'questions_supported': 'true',
'answer_{}'.format(question[0].pk): "",
}
)
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'ok'
@pytest.mark.django_db
def test_question_multiple_choice(client, env, question):
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
question[0].type = 'M'
question[0].save()
resp = client.post('/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={'secret': '1234', 'questions_supported': 'true'})
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['version'] == API_VERSION
assert jdata['status'] == 'incomplete'
assert jdata['questions'] == [
{
'id': question[0].pk,
'type': 'M',
'question': 'Size',
'required': True,
'position': question[0].position,
'options': [
{
'id': question[1].pk,
'answer': 'M'
},
{
'id': question[2].pk,
'answer': 'L'
}
]
}
]
resp = client.post(
'/pretixdroid/api/%s/%s/redeem/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'),
data={
'secret': '1234', 'questions_supported': 'true',
'answer_{}'.format(question[0].pk): "{},{}".format(question[1].pk, question[2].pk),
}
)
jdata = json.loads(resp.content.decode("utf-8"))
assert jdata['status'] == 'ok'
assert env[3].answers.get(question=question[0]).answer == 'M, L'
assert set(env[3].answers.get(question=question[0]).options.all()) == {question[1], question[2]}
@pytest.mark.django_db
def test_download_questions(client, env, question):
AppConfiguration.objects.create(event=env[0], key='abcdefg', list=env[5])
resp = client.get('/pretixdroid/api/%s/%s/download/?key=%s' % (env[0].organizer.slug, env[0].slug, 'abcdefg'))
jdata = json.loads(resp.content.decode("utf-8"))
assert len(jdata['results']) == 2
assert jdata['questions'] == [
{
'id': question[0].pk,
'type': 'C',
'question': 'Size',
'required': True,
'position': question[0].position,
'items': [env[3].item.pk],
'options': [
{
'id': question[1].pk,
'answer': 'M'
},
{
'id': question[2].pk,
'answer': 'L'
}
]
}
]