diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index a54974c6e6..e414f01f4a 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -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": [ { diff --git a/doc/plugins/pretixdroid.rst b/doc/plugins/pretixdroid.rst index abdfa45e18..b16b6b2bcd 100644 --- a/doc/plugins/pretixdroid.rst +++ b/doc/plugins/pretixdroid.rst @@ -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_=``, 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", diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index bebf50f59e..a7bc28fcc6 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -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): diff --git a/src/pretix/base/migrations/0080_question_ask_during_checkin.py b/src/pretix/base/migrations/0080_question_ask_during_checkin.py new file mode 100644 index 0000000000..e08c9082c2 --- /dev/null +++ b/src/pretix/base/migrations/0080_question_ask_during_checkin.py @@ -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'), + ), + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 0cbc0603b8..5df12d133a 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -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') diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 28665a3518..3d5c5b1c13 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -506,16 +506,16 @@ class QuestionAnswer(models.Model): return str(_("No")) elif self.question.type == Question.TYPE_FILE: return str(_("")) - 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] diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 723240fa1e..31909108f3 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -45,6 +45,7 @@ class QuestionForm(I18nModelForm): 'help_text', 'type', 'required', + 'ask_during_checkin', 'items' ] widgets = { diff --git a/src/pretix/control/templates/pretixcontrol/items/question_edit.html b/src/pretix/control/templates/pretixcontrol/items/question_edit.html index bf03685bb9..e4cdfd0a6e 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question_edit.html +++ b/src/pretix/control/templates/pretixcontrol/items/question_edit.html @@ -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" %}
diff --git a/src/pretix/control/templates/pretixcontrol/items/questions.html b/src/pretix/control/templates/pretixcontrol/items/questions.html index 3526ad8d30..0e6d36ee6f 100644 --- a/src/pretix/control/templates/pretixcontrol/items/questions.html +++ b/src/pretix/control/templates/pretixcontrol/items/questions.html @@ -42,7 +42,14 @@ {{ q.question }} - {{ q.get_type_display }} + + {{ q.get_type_display }} + {% if q.required %} + + + {% endif %} +
    {% for item in q.items.all %} diff --git a/src/pretix/control/templates/pretixcontrol/order/index.html b/src/pretix/control/templates/pretixcontrol/order/index.html index d0ce6a098a..2b89c0fb8f 100644 --- a/src/pretix/control/templates/pretixcontrol/order/index.html +++ b/src/pretix/control/templates/pretixcontrol/order/index.html @@ -213,7 +213,15 @@ {% trans "not answered" %}{% endif %} {% endif %} {% for q in line.questions %} -
    {{ q.question }}
    +
    + {{ q.question }} + {% if q.ask_during_checkin %} + + {% endif %} +
    {% if q.answer %} {% if q.answer.file %} diff --git a/src/pretix/plugins/pretixdroid/views.py b/src/pretix/plugins/pretixdroid/views.py index 21f93ddda3..ee50bb3a39 100644 --- a/src/pretix/plugins/pretixdroid/views.py +++ b/src/pretix/plugins/pretixdroid/views.py @@ -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) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 63affae32e..9e5a175e39 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -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.')) diff --git a/src/pretix/presale/forms/checkout.py b/src/pretix/presale/forms/checkout.py index be4042d42f..5e0bc3b61f 100644 --- a/src/pretix/presale/forms/checkout.py +++ b/src/pretix/presale/forms/checkout.py @@ -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, diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 71fd344728..8307e1ca07 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -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 diff --git a/src/pretix/presale/views/checkout.py b/src/pretix/presale/views/checkout.py index 1154443b21..971de7f407 100644 --- a/src/pretix/presale/views/checkout.py +++ b/src/pretix/presale/views/checkout.py @@ -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)) diff --git a/src/pretix/presale/views/order.py b/src/pretix/presale/views/order.py index ec055c0036..7a6ba380ae 100644 --- a/src/pretix/presale/views/order.py +++ b/src/pretix/presale/views/order.py @@ -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 diff --git a/src/pretix/presale/views/questions.py b/src/pretix/presale/views/questions.py index 26de21b4d2..2f6163afdc 100644 --- a/src/pretix/presale/views/questions.py +++ b/src/pretix/presale/views/questions.py @@ -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 diff --git a/src/tests/api/test_items.py b/src/tests/api/test_items.py index 9d466f874c..943f60e533 100644 --- a/src/tests/api/test_items.py +++ b/src/tests/api/test_items.py @@ -453,6 +453,7 @@ TEST_QUESTION_RES = { "type": "C", "required": False, "items": [], + "ask_during_checkin": False, "position": 0, "options": [ { diff --git a/src/tests/base/test_models.py b/src/tests/base/test_models.py index ead359c4f5..8c02be8a05 100644 --- a/src/tests/base/test_models.py +++ b/src/tests/base/test_models.py @@ -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] diff --git a/src/tests/plugins/pretixdroid/test_simple.py b/src/tests/plugins/pretixdroid/test_simple.py index 3e5ce0bb5d..b6c8ea8fdd 100644 --- a/src/tests/plugins/pretixdroid/test_simple.py +++ b/src/tests/plugins/pretixdroid/test_simple.py @@ -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' + } + ] + } + ]