mirror of
https://github.com/pretix/pretix.git
synced 2026-05-06 15:24:02 +00:00
@@ -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'),
|
||||
),
|
||||
]
|
||||
@@ -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')
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
|
||||
@@ -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.'))
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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))
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -453,6 +453,7 @@ TEST_QUESTION_RES = {
|
||||
"type": "C",
|
||||
"required": False,
|
||||
"items": [],
|
||||
"ask_during_checkin": False,
|
||||
"position": 0,
|
||||
"options": [
|
||||
{
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user