diff --git a/doc/api/resources/questions.rst b/doc/api/resources/questions.rst index ff2ecfc94f..bbf272b8ed 100644 --- a/doc/api/resources/questions.rst +++ b/doc/api/resources/questions.rst @@ -46,6 +46,16 @@ options list of objects In case of ques ├ identifier string An arbitrary string that can be used for matching with other sources. └ answer multi-lingual string The displayed value of this option +dependency_question integer Internal ID of a different question. The current + question will only be shown if the question given in + this attribute is set to the value given in + ``dependency_value``. This cannot be combined with + ``ask_during_checkin``. +dependency_value string The value ``dependency_question`` needs to be set to. + If ``dependency_question`` is set to a boolean + question, this should be ``"True"`` or ``"False"``. + Otherwise, it should be the ``identifier`` of a + question option. ===================================== ========================== ======================================================= .. versionchanged:: 1.12 @@ -100,6 +110,8 @@ Endpoints "position": 1, "identifier": "WY3TP9SL", "ask_during_checkin": false, + "dependency_question": null, + "dependency_value": null, "options": [ { "id": 1, @@ -165,6 +177,8 @@ Endpoints "position": 1, "identifier": "WY3TP9SL", "ask_during_checkin": false, + "dependency_question": null, + "dependency_value": null, "options": [ { "id": 1, @@ -214,6 +228,8 @@ Endpoints "items": [1, 2], "position": 1, "ask_during_checkin": false, + "dependency_question": null, + "dependency_value": null, "options": [ { "answer": {"en": "S"} @@ -245,6 +261,8 @@ Endpoints "position": 1, "identifier": "WY3TP9SL", "ask_during_checkin": false, + "dependency_question": null, + "dependency_value": null, "options": [ { "id": 1, @@ -314,6 +332,8 @@ Endpoints "position": 2, "identifier": "WY3TP9SL", "ask_during_checkin": false, + "dependency_question": null, + "dependency_value": null, "options": [ { "id": 1, diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 43ee660e5f..eb91ef8268 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -159,12 +159,20 @@ class QuestionSerializer(I18nAwareModelSerializer): class Meta: model = Question fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', - 'ask_during_checkin', 'identifier') + 'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value') def validate_identifier(self, value): Question._clean_identifier(self.context['event'], value, self.instance) return value + def validate_dependency_question(self, value): + if value: + if value.type not in (Question.TYPE_CHOICE, Question.TYPE_BOOLEAN, Question.TYPE_CHOICE_MULTIPLE): + raise ValidationError('Question dependencies can only be set to boolean or choice questions.') + if value == self.instance: + raise ValidationError('A question cannot depend on itself.') + return value + def validate(self, data): data = super().validate(data) if self.instance and 'options' in data: @@ -176,6 +184,18 @@ class QuestionSerializer(I18nAwareModelSerializer): full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {} full_data.update(data) + if full_data.get('ask_during_checkin') and full_data.get('dependency_question'): + raise ValidationError('Dependencies are not supported during check-in.') + + dep = full_data.get('dependency_question') + if dep: + seen_ids = {self.instance.pk} if self.instance else set() + while dep: + if dep.pk in seen_ids: + raise ValidationError(_('Circular dependency between questions detected.')) + seen_ids.add(dep.pk) + dep = dep.dependency_question + Question.clean_items(event, full_data.get('items')) return data diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index d8b3ef4c2c..ddf7778531 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -17,7 +17,7 @@ from pretix.base.forms.widgets import ( BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget, UploadedFileWidget, ) -from pretix.base.models import InvoiceAddress, Question +from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models.tax import EU_COUNTRIES from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.templatetags.rich_text import rich_text @@ -145,6 +145,7 @@ class BaseQuestionsForm(forms.Form): item = pos.item questions = pos.item.questions_to_ask event = kwargs.pop('event') + self.all_optional = kwargs.pop('all_optional', False) super().__init__(*args, **kwargs) @@ -173,6 +174,7 @@ class BaseQuestionsForm(forms.Form): tz = pytz.timezone(event.settings.timezone) help_text = rich_text(q.help_text) label = escape(q.question) # django-bootstrap3 calls mark_safe + required = q.required and not self.all_optional if q.type == Question.TYPE_BOOLEAN: if q.required: # For some reason, django-bootstrap3 does not set the required attribute @@ -187,26 +189,26 @@ class BaseQuestionsForm(forms.Form): initialbool = False field = forms.BooleanField( - label=label, required=q.required, + label=label, required=required, help_text=help_text, initial=initialbool, widget=widget, ) elif q.type == Question.TYPE_NUMBER: field = forms.DecimalField( - label=label, required=q.required, + label=label, required=required, help_text=q.help_text, initial=initial.answer if initial else None, min_value=Decimal('0.00'), ) elif q.type == Question.TYPE_STRING: field = forms.CharField( - label=label, required=q.required, + label=label, required=required, help_text=help_text, initial=initial.answer if initial else None, ) elif q.type == Question.TYPE_TEXT: field = forms.CharField( - label=label, required=q.required, + label=label, required=required, help_text=help_text, widget=forms.Textarea, initial=initial.answer if initial else None, @@ -214,44 +216,46 @@ class BaseQuestionsForm(forms.Form): elif q.type == Question.TYPE_CHOICE: field = forms.ModelChoiceField( queryset=q.options, - label=label, required=q.required, + label=label, required=required, help_text=help_text, widget=forms.Select, + to_field_name='identifier', empty_label='', initial=initial.options.first() if initial else None, ) elif q.type == Question.TYPE_CHOICE_MULTIPLE: field = forms.ModelMultipleChoiceField( queryset=q.options, - label=label, required=q.required, + label=label, required=required, help_text=help_text, + to_field_name='identifier', widget=forms.CheckboxSelectMultiple, initial=initial.options.all() if initial else None, ) elif q.type == Question.TYPE_FILE: field = forms.FileField( - label=label, required=q.required, + label=label, required=required, help_text=help_text, initial=initial.file if initial else None, widget=UploadedFileWidget(position=pos, event=event, answer=initial), ) elif q.type == Question.TYPE_DATE: field = forms.DateField( - label=label, required=q.required, + label=label, required=required, help_text=help_text, initial=dateutil.parser.parse(initial.answer).date() if initial and initial.answer else None, widget=DatePickerWidget(), ) elif q.type == Question.TYPE_TIME: field = forms.TimeField( - label=label, required=q.required, + label=label, required=required, help_text=help_text, initial=dateutil.parser.parse(initial.answer).time() if initial and initial.answer else None, widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), ) elif q.type == Question.TYPE_DATETIME: field = SplitDateTimeField( - label=label, required=q.required, + label=label, required=required, help_text=help_text, initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None, widget=SplitDateTimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')), @@ -260,6 +264,15 @@ class BaseQuestionsForm(forms.Form): if answers: # Cache the answer object for later use field.answer = answers[0] + + if q.dependency_question_id: + field.widget.attrs['data-question-dependency'] = q.dependency_question_id + field.widget.attrs['data-question-dependency-value'] = q.dependency_value + if q.type != 'M': + field.widget.attrs['required'] = q.required and not self.all_optional + field._required = q.required and not self.all_optional + field.required = False + self.fields['question_%s' % q.id] = field responses = question_form_fields.send(sender=event, position=pos) @@ -270,6 +283,40 @@ class BaseQuestionsForm(forms.Form): self.fields[key] = value value.initial = data.get('question_form_data', {}).get(key) + def clean(self): + d = super().clean() + + question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)} + + def question_is_visible(parentid, qval): + parentq = question_cache[parentid] + if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value): + return False + if 'question_%d' % parentid not in d: + return False + dval = d.get('question_%d' % parentid) + if qval == 'True': + return dval + elif qval == 'False': + return not dval + elif isinstance(dval, QuestionOption): + return dval.identifier == qval + else: + return qval in [o.identifier for o in dval] + + def question_is_required(q): + return ( + q.required and + (not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value)) + ) + + if not self.all_optional: + for q in question_cache.values(): + if question_is_required(q) and not d.get('question_%d' % q.pk): + raise ValidationError({'question_%d' % q.pk: [_('This field is required')]}) + + return d + class BaseInvoiceAddressForm(forms.ModelForm): vat_warning = False diff --git a/src/pretix/base/migrations/0113_auto_20190312_0942.py b/src/pretix/base/migrations/0113_auto_20190312_0942.py new file mode 100644 index 0000000000..308c69685d --- /dev/null +++ b/src/pretix/base/migrations/0113_auto_20190312_0942.py @@ -0,0 +1,27 @@ +# Generated by Django 2.1.5 on 2019-03-12 09:42 + +import django.db.models.deletion +import jsonfallback.fields +from django.db import migrations, models + +import pretix.base.models.fields + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0112_auto_20190304_1726'), + ] + + operations = [ + migrations.AddField( + model_name='question', + name='dependency_question', + field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.Question'), + ), + migrations.AddField( + model_name='question', + name='dependency_value', + field=models.TextField(blank=True, null=True), + ), + ] diff --git a/src/pretix/base/models/event.py b/src/pretix/base/models/event.py index 8a3f3e22b0..21cc43199f 100644 --- a/src/pretix/base/models/event.py +++ b/src/pretix/base/models/event.py @@ -515,6 +515,10 @@ class Event(EventMixin, LoggedModel): o.question = q o.save() + for q in self.questions.filter(dependency_question__isnull=False): + q.dependency_question = question_map[q.dependency_question_id] + q.save(update_fields=['dependency_question']) + for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'): items = list(cl.limit_products.all()) cl.pk = None diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index d40a7d6b8d..8e14595fa2 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -702,6 +702,10 @@ class Question(LoggedModel): :type ask_during_checkin: bool :param identifier: An arbitrary, internal identifier :type identifier: str + :param dependency_question: This question will only show up if the referenced question is set to `dependency_value`. + :type dependency_question: Question + :param dependency_value: The value that `dependency_question` needs to be set to for this question to be applicable. + :type dependency_value: str """ TYPE_NUMBER = "N" TYPE_STRING = "S" @@ -771,6 +775,10 @@ class Question(LoggedModel): 'pretixdesk 0.2 or newer.'), default=False ) + dependency_question = models.ForeignKey( + 'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions' + ) + dependency_value = models.TextField(null=True, blank=True) class Meta: verbose_name = _("Question") diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py index cc7e7f3db4..0ace5d315f 100644 --- a/src/pretix/base/views/mixins.py +++ b/src/pretix/base/views/mixins.py @@ -17,6 +17,7 @@ from pretix.base.models import ( class BaseQuestionsViewMixin: form_class = BaseQuestionsForm + all_optional = False @staticmethod def _keyfunc(pos): @@ -47,6 +48,7 @@ class BaseQuestionsViewMixin: prefix=cr.id, cartpos=cartpos, orderpos=orderpos, + all_optional=self.all_optional, data=(self.request.POST if self.request.method == 'POST' else None), files=(self.request.FILES if self.request.method == 'POST' else None)) form.pos = cartpos or orderpos @@ -154,7 +156,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin): @cached_property def positions(self): - qqs = Question.objects.all() + qqs = self.request.event.questions.all() if self.only_user_visible: qqs = qqs.filter(ask_during_checkin=False) return list(self.order.positions.select_related( @@ -173,7 +175,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin): Question.objects.none(), to_attr='dummy' ))) - ), + ).select_related('dependency_question'), to_attr='questions_to_ask') )) diff --git a/src/pretix/control/forms/item.py b/src/pretix/control/forms/item.py index 49345c66f2..4292f532ea 100644 --- a/src/pretix/control/forms/item.py +++ b/src/pretix/control/forms/item.py @@ -36,14 +36,39 @@ class CategoryForm(I18nModelForm): class QuestionForm(I18nModelForm): question = I18nFormField( label=_("Question"), - widget_kwargs={'attrs': {'rows': 5}}, + widget_kwargs={'attrs': {'rows': 2}}, widget=I18nTextarea ) def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.fields['items'].queryset = self.instance.event.items.all() + self.fields['dependency_question'].queryset = self.instance.event.questions.filter( + type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE) + ) + if self.instance.pk: + self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude( + pk=self.instance.pk + ) self.fields['identifier'].required = False + self.fields['help_text'].widget.attrs['rows'] = 3 + + def clean_dependency_question(self): + dep = val = self.cleaned_data.get('dependency_question') + if dep: + seen_ids = {self.instance.pk} if self.instance else set() + while dep: + if dep.pk in seen_ids: + raise ValidationError(_('Circular dependency between questions detected.')) + seen_ids.add(dep.pk) + dep = dep.dependency_question + return val + + def clean(self): + d = super().clean() + if d.get('dependency_question') and not d.get('dependency_value'): + raise ValidationError({'dependency_value': [_('This field is required')]}) + return d class Meta: model = Question @@ -55,12 +80,15 @@ class QuestionForm(I18nModelForm): 'required', 'ask_during_checkin', 'identifier', - 'items' + 'items', + 'dependency_question', + 'dependency_value' ] widgets = { 'items': forms.CheckboxSelectMultiple( attrs={'class': 'scrolling-multiple-choice'} ), + 'dependency_value': forms.Select, } diff --git a/src/pretix/control/templates/pretixcontrol/base.html b/src/pretix/control/templates/pretixcontrol/base.html index 9b16e07fd1..49b1484947 100644 --- a/src/pretix/control/templates/pretixcontrol/base.html +++ b/src/pretix/control/templates/pretixcontrol/base.html @@ -33,6 +33,7 @@ + @@ -70,7 +71,10 @@