diff --git a/doc/api/resources/orders.rst b/doc/api/resources/orders.rst index 929f1d4541..2b4ed2b9f1 100644 --- a/doc/api/resources/orders.rst +++ b/doc/api/resources/orders.rst @@ -129,7 +129,9 @@ downloads list of objects List of ticket answers list of objects Answers to user-defined questions ├ question integer Internal ID of the answered question ├ answer string Text representation of the answer -└ options list of integers Internal IDs of selected option(s)s (only for choice types) +├ question_identifier string The question's ``identifier`` field +├ options list of integers Internal IDs of selected option(s)s (only for choice types) +└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s ===================================== ========================== ======================================================= .. versionchanged:: 1.7 @@ -140,6 +142,10 @@ answers list of objects Answers to user The attribute ``checkins.list`` has been added. +.. versionchanged:: 1.14 + + The attributes ``answers.question_identifier`` and ``answers.option_identifiers`` have been added. + Order endpoints --------------- @@ -222,7 +228,9 @@ Order endpoints "answers": [ { "question": 12, + "question_identifier": "WY3TP9SL", "answer": "Foo", + "option_idenfiters": [], "options": [] } ], @@ -330,7 +338,9 @@ Order endpoints "answers": [ { "question": 12, + "question_identifier": "WY3TP9SL", "answer": "Foo", + "option_idenfiters": [], "options": [] } ], @@ -649,7 +659,9 @@ Order position endpoints "answers": [ { "question": 12, + "question_identifier": "WY3TP9SL", "answer": "Foo", + "option_idenfiters": [], "options": [] } ], @@ -729,7 +741,9 @@ Order position endpoints "answers": [ { "question": 12, + "question_identifier": "WY3TP9SL", "answer": "Foo", + "option_idenfiters": [], "options": [] } ], diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index 9de261704c..740622c2e3 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -31,12 +31,16 @@ 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. +identifier string An arbitrary string that can be used for matching with + other sources. 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 +├ identifier string An arbitrary string that can be used for matching with + other sources. └ answer multi-lingual string The displayed value of this option ===================================== ========================== ======================================================= @@ -45,6 +49,10 @@ options list of objects In case of ques The values ``D``, ``H``, and ``W`` for the field ``type`` are now allowed and the ``ask_during_checkin`` field has been added. +.. versionchanged:: 1.14 + + The attribute ``identifier`` has been added to both the resource itself and the ``options`` subresource. + Endpoints --------- @@ -80,18 +88,22 @@ Endpoints "required": false, "items": [1, 2], "position": 1, + "identifier": "WY3TP9SL", "ask_during_checkin": false, "options": [ { "id": 1, + "identifier": "LVETRWVU", "answer": {"en": "S"} }, { "id": 2, + "identifier": "DFEMJWMJ", "answer": {"en": "M"} }, { "id": 3, + "identifier": "W9AH7RDE", "answer": {"en": "L"} } ] @@ -134,19 +146,23 @@ Endpoints "type": "C", "required": false, "items": [1, 2], - "ask_during_checkin": false, "position": 1, + "identifier": "WY3TP9SL", + "ask_during_checkin": false, "options": [ { "id": 1, + "identifier": "LVETRWVU", "answer": {"en": "S"} }, { "id": 2, + "identifier": "DFEMJWMJ", "answer": {"en": "M"} }, { "id": 3, + "identifier": "W9AH7RDE", "answer": {"en": "L"} } ] diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 4bdde2a18c..b29c0f34e2 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -142,7 +142,7 @@ class InlineQuestionOptionSerializer(I18nAwareModelSerializer): class Meta: model = QuestionOption - fields = ('id', 'answer') + fields = ('id', 'identifier', 'answer') class QuestionSerializer(I18nAwareModelSerializer): @@ -151,7 +151,7 @@ class QuestionSerializer(I18nAwareModelSerializer): class Meta: model = Question fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', - 'ask_during_checkin') + 'ask_during_checkin', 'identifier') class QuotaSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index ad959f989c..8141fc1b93 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -29,10 +29,23 @@ class InvoiceAdddressSerializer(I18nAwareModelSerializer): 'vat_id_validated', 'internal_reference') +class AnswerQuestionIdentifierField(serializers.Field): + def to_representation(self, instance: QuestionAnswer): + return instance.question.identifier + + +class AnswerQuestionOptionsIdentifierField(serializers.Field): + def to_representation(self, instance: QuestionAnswer): + return [o.identifier for o in instance.options.all()] + + class AnswerSerializer(I18nAwareModelSerializer): + question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) + option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) + class Meta: model = QuestionAnswer - fields = ('question', 'answer', 'options') + fields = ('question', 'answer', 'question_identifier', 'options', 'option_identifiers') class CheckinSerializer(I18nAwareModelSerializer): diff --git a/src/pretix/api/views/order.py b/src/pretix/api/views/order.py index 29a9d85eaf..a1eba0cc82 100644 --- a/src/pretix/api/views/order.py +++ b/src/pretix/api/views/order.py @@ -50,7 +50,7 @@ class OrderViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return self.request.event.orders.prefetch_related( 'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options', - 'fees' + 'positions__answers__questions', 'fees' ).select_related( 'invoice_address' ) @@ -234,7 +234,7 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet): def get_queryset(self): return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related( - 'checkins', 'answers', 'answers__options' + 'checkins', 'answers', 'answers__options', 'answers__question' ).select_related( 'item', 'order', 'order__event', 'order__event__organizer' ) diff --git a/src/pretix/base/migrations/0085_auto_20180312_1119.py b/src/pretix/base/migrations/0085_auto_20180312_1119.py new file mode 100644 index 0000000000..2380246bd2 --- /dev/null +++ b/src/pretix/base/migrations/0085_auto_20180312_1119.py @@ -0,0 +1,59 @@ +# -*- coding: utf-8 -*- +# Generated by Django 1.11.11 on 2018-03-12 11:19 +from __future__ import unicode_literals + +from django.db import migrations, models +from django.utils.crypto import get_random_string + + +def set_identifiers(apps, schema_editor): + Question = apps.get_model('pretixbase', 'Question') + QuestionOption = apps.get_model('pretixbase', 'QuestionOption') + + for q in Question.objects.select_related('event'): + if not q.identifier: + charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') + while True: + code = get_random_string(length=8, allowed_chars=charset) + if not Question.objects.filter(event=q.event, identifier=code).exists(): + q.identifier = code + q.save() + break + + for q in QuestionOption.objects.select_related('question', 'question__event'): + if not q.identifier: + charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') + while True: + code = get_random_string(length=8, allowed_chars=charset) + if not QuestionOption.objects.filter(question__event=q.question.event, identifier=code).exists(): + q.identifier = code + q.save() + break + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0084_questionoption_position'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='identifier', + field=models.CharField(default='', max_length=190), + preserve_default=False, + ), + migrations.AddField( + model_name='questionoption', + name='identifier', + field=models.CharField(default='', max_length=190), + preserve_default=False, + ), + migrations.AlterField( + model_name='user', + name='locale', + field=models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)'), ('nl', 'Dutch'), ('da', 'Danish'), ('pt-br', 'Portuguese (Brazil)')], default='en', max_length=50, verbose_name='Language'), + ), + migrations.RunPython(set_identifiers, migrations.RunPython.noop) + ] diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 39761ee4a6..ad9a19f227 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -11,6 +11,7 @@ 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.crypto import get_random_string from django.utils.functional import cached_property from django.utils.timezone import is_naive, make_aware, now from django.utils.translation import pgettext_lazy, ugettext_lazy as _ @@ -630,6 +631,8 @@ class Question(LoggedModel): :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 + :param identifier: An arbitrary, internal identifier + :type identifier: str """ TYPE_NUMBER = "N" TYPE_STRING = "S" @@ -661,6 +664,12 @@ class Question(LoggedModel): question = I18nTextField( verbose_name=_("Question") ) + identifier = models.CharField( + max_length=190, + verbose_name=_("Internal identifier"), + help_text=_('You can enter any value here to make it easier to match the data with other sources. If you do ' + 'not input one, we will generate one automatically.') + ) help_text = I18nTextField( verbose_name=_("Help text"), help_text=_("If the question needs to be explained or clarified, do it here!"), @@ -706,7 +715,18 @@ class Question(LoggedModel): if self.event: self.event.cache.clear() + def clean_identifier(self, code): + if Question.objects.filter(event=self.event, identifier=code).exclude(pk=self.pk).exists(): + raise ValidationError(_('This identifier is already used for a different question.')) + def save(self, *args, **kwargs): + if not self.identifier: + charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') + while True: + code = get_random_string(length=8, allowed_chars=charset) + if not Question.objects.filter(event=self.event, identifier=code).exists(): + self.identifier = code + break super().save(*args, **kwargs) if self.event: self.event.cache.clear() @@ -779,12 +799,23 @@ class Question(LoggedModel): class QuestionOption(models.Model): question = models.ForeignKey('Question', related_name='options') + identifier = models.CharField(max_length=190) answer = I18nCharField(verbose_name=_('Answer')) position = models.IntegerField(default=0) def __str__(self): return str(self.answer) + def save(self, *args, **kwargs): + if not self.identifier: + charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') + while True: + code = get_random_string(length=8, allowed_chars=charset) + if not QuestionOption.objects.filter(question__event=self.question.event, identifier=code).exists(): + self.identifier = code + break + super().save(*args, **kwargs) + class Meta: verbose_name = _("Question option") verbose_name_plural = _("Question options") diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 20ff8eea23..65bcca3a62 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -41,6 +41,7 @@ class QuestionForm(I18nModelForm): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['items'].queryset = self.instance.event.items.all() + self.fields['identifier'].required = False class Meta: model = Question @@ -51,6 +52,7 @@ class QuestionForm(I18nModelForm): 'type', 'required', 'ask_during_checkin', + 'identifier', '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 b963a079d4..6b8e085863 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.identifier layout="control" %} {% bootstrap_field form.ask_during_checkin layout="control" %} {% bootstrap_field form.required layout="control" %} @@ -54,10 +55,16 @@