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
+7 -1
View File
@@ -29,6 +29,9 @@ type string The expected ty
required boolean If ``True``, the question needs to be filled out.
position integer An integer, used for sorting
items list of integers List of item IDs this question is assigned to.
ask_during_checkin boolean If ``True``, this question will not be asked while
buying the ticket, but will show up when redeeming
the ticket instead.
options list of objects In case of question type ``C`` or ``M``, this lists the
available objects.
├ id integer Internal ID of the option
@@ -37,7 +40,8 @@ options list of objects In case of ques
.. versionchanged:: 1.12
The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed.
The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed and the ``ask_during_checkin`` field has
been added.
Endpoints
---------
@@ -74,6 +78,7 @@ Endpoints
"required": false,
"items": [1, 2],
"position": 1,
"ask_during_checkin": false,
"options": [
{
"id": 1,
@@ -127,6 +132,7 @@ Endpoints
"type": "C",
"required": false,
"items": [1, 2],
"ask_during_checkin": false,
"position": 1,
"options": [
{
+132 -11
View File
@@ -9,6 +9,13 @@ uses to communicate with the pretix server.
general-purpose :ref:`rest-api` that not yet provides all features that this API provides, but will do
so in the future.
.. versionchanged:: 1.12
Support for check-in-time questions has been added. The new API features are fully backwards-compatible and
negotiated live, so clients which do not need this feature can ignore the change. For this reason, the API version
has not been increased and is still set to 3.
.. http:post:: /pretixdroid/api/(organizer)/(event)/redeem/
Redeems a ticket, i.e. checks the user in.
@@ -22,18 +29,30 @@ uses to communicate with the pretix server.
Accept: application/json, text/javascript
Content-Type: application/x-www-form-urlencoded
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3
secret=az9u4mymhqktrbupmwkvv6xmgds5dk3&questions_supported=true
You can optionally include the additional parameter ``datetime`` in the body containing an ISO8601-encoded
datetime of the entry attempt. If you don't, the current date and time will be used.
You **must** set the parameter secret.
You can optionally include the additional parameter ``force`` to indicate that the request should be logged
You **must** set the parameter ``questions_supported`` to ``true`` **if** you support asking questions
back to the app operator. You **must not** set it if you do not support this feature. In that case, questions
will just be ignored.
You **may** set the additional parameter ``datetime`` in the body containing an ISO8601-encoded
datetime of the entry attempt. If you don"t, the current date and time will be used.
You **may** set the additional parameter ``force`` to indicate that the request should be logged
regardless of previous check-ins for the same ticket. This might be useful if you made the entry decision offline.
Questions will also always be ignored in this case (i.e. supplied answers will be saved, but no error will be
thrown if they are missing or invalid).
You can optionally include the additional parameter ``nonce`` with a globally unique random value to identify this
You **may** set the additional parameter ``nonce`` with a globally unique random value to identify this
check-in. This is meant to be used to prevent duplicate check-ins when you are just retrying after a connection
failure.
If questions are supported and required, you will receive a dictionary ``questions`` containing details on the
particular questions to ask. To answer them, just re-send your redemption request with additional parameters of
the form ``answer_<question>=<answer>``, e.g. ``answer_12=24``.
**Example successful response**:
.. sourcecode:: http
@@ -43,10 +62,66 @@ uses to communicate with the pretix server.
{
"status": "ok"
"version": 2
"version": 3,
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"paid": true
}
}
**Example error response**:
**Example response with required questions**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "incomplete"
"version": 3
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"paid": true
},
"questions": [
{
"id": 12,
"type": "C",
"question": "Choose a shirt size",
"required": true,
"position": 2,
"items": [1],
"options": [
{
"id": 24,
"answer": "M"
},
{
"id": 25,
"answer": "L"
}
]
}
]
}
**Example error response with data**:
.. sourcecode:: http
@@ -56,13 +131,39 @@ uses to communicate with the pretix server.
{
"status": "error",
"reason": "already_redeemed",
"version": 2
"version": 3,
"data": {
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
"order": "ABCDE",
"item": "Standard ticket",
"item_id": 1,
"variation": null,
"variation_id": null,
"attendee_name": "Peter Higgs",
"attention": false,
"redeemed": true,
"paid": true
}
}
**Example error response without data**:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: text/json
{
"status": "error",
"reason": "unkown_ticket",
"version": 3
}
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
* ``unknown_ticket`` - Secret does not match a ticket in the database
:query key: Secret API key
@@ -104,7 +205,7 @@ uses to communicate with the pretix server.
},
...
],
"version": 2
"version": 3
}
:query query: Search query
@@ -133,6 +234,7 @@ uses to communicate with the pretix server.
Content-Type: text/json
{
"version": 3,
"results": [
{
"secret": "az9u4mymhqktrbupmwkvv6xmgds5dk3",
@@ -146,7 +248,26 @@ uses to communicate with the pretix server.
},
...
],
"version": 2
"questions": [
{
"id": 12,
"type": "C",
"question": "Choose a shirt size",
"required": true,
"position": 2,
"items": [1],
"options": [
{
"id": 24,
"answer": "M"
},
{
"id": 25,
"answer": "L"
}
]
}
]
}
:query key: Secret API key
@@ -177,7 +298,7 @@ uses to communicate with the pretix server.
{
"checkins": 17,
"total": 42,
"version": 2,
"version": 3,
"event": {
"name": "Demo Converence",
"slug": "democon",
+2 -1
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):
@@ -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'),
),
]
+72 -3
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')
+11 -5
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]
+1
View File
@@ -45,6 +45,7 @@ class QuestionForm(I18nModelForm):
'help_text',
'type',
'required',
'ask_during_checkin',
'items'
]
widgets = {
@@ -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>
@@ -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 %}
@@ -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 %}
+94 -4
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)
+2 -2
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.'))
+4 -8
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,
+11 -3
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
+3 -2
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))
+19 -3
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
+22 -2
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
+1
View File
@@ -453,6 +453,7 @@ TEST_QUESTION_RES = {
"type": "C",
"required": False,
"items": [],
"ask_during_checkin": False,
"position": 0,
"options": [
{
+105
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]
@@ -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'
}
]
}
]