From 95cd0d5e3892eced9424929988044fe145c2c74a Mon Sep 17 00:00:00 2001 From: Mira Weller Date: Fri, 7 Aug 2026 21:26:31 +0200 Subject: [PATCH] Squashed commit of the following: commit 2177bebf6ab7e4d4756879653e2e2595d6f02cfe Merge: ea613bf271 d08216d8c5 Author: Mira Weller Date: Fri Aug 7 21:22:31 2026 +0200 Merge branch 'master' into questions-vue3 commit ea613bf27128af47c1148d495df45a3aa33140de Author: Mira Weller Date: Fri Aug 7 21:21:35 2026 +0200 wip commit c8bed5e3b9d60cac6b3eed6a32897b4b430aa8c3 Author: Mira Weller Date: Fri Aug 7 10:15:00 2026 +0200 rename get_cart to get_cart_positions (keep old name alive) commit 1ec27fff05d68e90cd5fbc22832c7aa06d9a1d47 Author: Mira Weller Date: Fri Aug 7 10:01:38 2026 +0200 filter questionnaires by sales channel commit 0294f205d084601b734eec0b41adfcd97da1dd6e Author: Mira Weller Date: Thu Aug 6 20:06:05 2026 +0200 refactor: extract build_requested_valid_from_field method commit 3424aacdc342bfa3390cc8597b121810112917b2 Author: Mira Weller Date: Thu Aug 6 20:05:44 2026 +0200 rename presale question forms and mixins commit 4012889076cca76e31c0cbaf9840132f9964688f Author: Mira Weller Date: Thu Aug 6 19:42:29 2026 +0200 rebase migration commit fe15d71cd24bf0e1bdc3b825741e457df672849f Merge: 3ea7ea31b6 4d9dfa88fe Author: Mira Weller Date: Thu Aug 6 19:25:44 2026 +0200 Merge branch 'master' into questions-vue3 # Conflicts: # src/pretix/base/forms/questions.py # src/pretix/base/middleware.py # src/pretix/base/templates/pretixbase/redirect.html commit 3ea7ea31b645b468ff6b298a81a344f9fbe3f080 Author: Mira Weller Date: Fri Jul 24 17:37:46 2026 +0200 wip commit 5b34a25fb579b86ac6b236044d514c210981938c Author: Mira Weller Date: Wed Jun 17 16:21:11 2026 +0200 use questionnaire data for rendering checkout questions step commit 9467f1d08d63a7fdeefaaed1a77088159d779110 Author: Mira Weller Date: Wed Jun 17 15:54:14 2026 +0200 refactor: extract build_system_question_field method commit 85344a7690c6dffd478879e70bafeabdcf50d6de Author: Mira Weller Date: Wed Jun 17 15:50:41 2026 +0200 refactor: extract build_user_question_field method commit 3b47f4aaa7ccecef72057850ede306ef62015dea Author: Mira Weller Date: Wed Jun 17 15:49:34 2026 +0200 refactor: cartpos / orderpos commit 0309b0512123d1b0a088d554c9ff129dbac44074 Author: Mira Weller Date: Thu Mar 19 13:52:18 2026 +0100 vite config: allow cors access from pretix.work subdomains commit ad092ae0d14e59d4e7cfad1ff0749abbd711d2bb Author: Mira Weller Date: Wed Jun 17 12:35:33 2026 +0200 re-add vue-slicksort dep commit 9f42d679cc9c8d9e5e666d7dbaf440fcc1cc665f Author: Mira Weller Date: Wed Jun 17 12:28:53 2026 +0200 rebase migration commit 9eae9c6de7fef6443adc3e03066cf1fc31364baa Merge: 84c51e031e 694b915d89 Author: Mira Weller Date: Wed Jun 17 12:24:49 2026 +0200 Merge branch 'master' into questions-vue3 # Conflicts: # .github/workflows/tests.yml # .gitignore # Dockerfile # package-lock.json # package.json # src/pretix/base/management/commands/runserver.py # src/pretix/base/middleware.py # src/pretix/base/templatetags/vite.py # src/pretix/control/forms/global_settings.py # src/pretix/control/templates/pretixcontrol/checkin/list_edit.html # src/pretix/control/views/item.py # src/pretix/presale/views/widget.py # src/pretix/settings.py # src/pretix/static/pretixcontrol/js/ui/checkinrules/App.vue # src/pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts # src/pretix/static/pretixcontrol/js/ui/checkinrules/django-interop.ts # src/pretix/static/pretixcontrol/js/ui/checkinrules/lookup-select2.vue # src/pretix/static/pretixcontrol/js/ui/checkinrules/timefield.vue # src/pretix/static/pretixcontrol/js/ui/checkinrules/viz-node.vue # src/pretix/static/pretixpresale/widget/index.html # src/pretix/static/pretixpresale/widget/src/api.ts # src/pretix/static/pretixpresale/widget/src/button.ts # src/pretix/static/pretixpresale/widget/src/components/PriceBox.vue # src/pretix/static/pretixpresale/widget/src/main.ts # src/pretix/static/pretixpresale/widget/src/sharedStore.ts # src/pretix/static/pretixpresale/widget/src/utils.ts # src/pretix/static/pretixpresale/widget/src/widget.ts # src/tests/e2e/conftest.py # vite.config.ts commit 84c51e031e812163449ebf8271231ffa7a3dfe8e Merge: 902ee9242e 222b453b43 Author: Mira Weller Date: Wed Jun 17 12:20:05 2026 +0200 Merge branch 'master' into questions-vue3 commit 902ee9242e7312f69ac521fb6a644eb52761af7e Author: Mira Weller Date: Wed Jun 17 12:19:26 2026 +0200 css comment commit b758efd4a9f364c4e3c20c212e6c03a4145937fe Author: Mira Weller Date: Wed Jun 17 12:19:19 2026 +0200 question dependencies commit c68fe0050267c2022f93af7b2478cecf1a04c979 Author: Mira Weller Date: Wed Jun 17 12:18:00 2026 +0200 add data field edit link commit b7da9bb6c14bc566dbe616d8892eb35156e324df Author: Mira Weller Date: Wed Jun 17 12:16:20 2026 +0200 handle hidden questions commit 897120d3da3c4bdaeab95586837afb2b85d16337 Author: Mira Weller Date: Tue May 12 13:04:08 2026 +0200 add ts model definitions commit 09abc5725efa99432775fc44decfeb003b9988f8 Author: Mira Weller Date: Tue May 12 11:30:12 2026 +0200 reorder and save commit 9a7d5abb1f3c3d39469f29dcca1fca65de1b59fd Author: Mira Weller Date: Tue May 12 11:29:37 2026 +0200 fix unhandled exception in taxrules API commit 3cee5f524bbae4b12b59753f1808d7b38902c5f0 Author: Mira Weller Date: Tue May 5 18:12:21 2026 +0200 use slicksort for reordering questionnaires and questions commit 3a1a6b988e7cade51c0039e02d918d4f060cf0ba Author: Mira Weller Date: Tue Apr 28 19:23:35 2026 +0200 improve i18n, implement "add text block" dialog commit bdc720f5a2418fcdec1bfc3b9046c22e0741c968 Author: Mira Weller Date: Fri Mar 27 18:57:22 2026 +0100 wip commit 583184af380692c850cc42be86aeea7cbcd8d25b Author: Mira Weller Date: Thu Mar 19 21:45:32 2026 +0100 change existing views commit 63d9f7cea80be386267a7edcbd4efe727a9775e7 Author: Mira Weller Date: Thu Mar 19 21:45:32 2026 +0100 adapt vue app to new data model (wip) commit c3d6fb1bd6d05238502d2a404528af85161a335e Author: Mira Weller Date: Thu Mar 19 21:44:47 2026 +0100 add Questionnaire and QuestionnaireChild models and their API; migrate existing data commit bb43acd25705792cfd1081462a75ee7e4cd88c83 Author: Mira Weller Date: Thu Mar 19 13:52:28 2026 +0100 vite config: add questionnaires app commit e0744951e843a601b31b0f3b9825d2d4275585dd Author: Mira Weller Date: Thu Mar 19 13:52:18 2026 +0100 vite config: allow cors access from pretix.work subdomains commit fef1e356f7ac9745e492d09724890e1872c2722a Author: Mira Weller Date: Thu Mar 19 13:33:24 2026 +0100 fix out-of-the-box experience commit 81775f5d2d3ffeac9edb717b6595af8ee6ac71a1 Author: Mira Weller Date: Thu Mar 19 13:33:24 2026 +0100 setup questionnaires vue app, add code from question editor proof of concept commit 4bbcc398cc1d24234017499f8b8d0aea80b2c179 Merge: a924d0a266 58840a5fd6 Author: Mira Weller Date: Thu Mar 19 13:13:32 2026 +0100 Merge remote-tracking branch 'refs/remotes/origin/master' into questions-vue3 --- package-lock.json | 12 +- package.json | 3 +- src/pretix/api/serializers/event.py | 2 +- src/pretix/api/serializers/item.py | 158 +++- src/pretix/api/urls.py | 3 +- src/pretix/api/views/item.py | 48 +- src/pretix/base/forms/questions.py | 769 +++++++++--------- .../0308_questionnaire_questionnairechild.py | 175 ++++ src/pretix/base/models/__init__.py | 2 +- src/pretix/base/models/items.py | 133 ++- src/pretix/base/models/orders.py | 97 ++- src/pretix/base/payment.py | 6 +- src/pretix/base/views/mixins.py | 54 +- src/pretix/base/views/redirect.py | 2 + src/pretix/control/logdisplay.py | 3 + src/pretix/control/navigation.py | 6 +- .../pretixcontrol/items/question_edit.html | 231 +++--- .../pretixcontrol/items/questionnaires.html | 41 + .../pretixcontrol/items/questions.html | 65 +- .../templates/pretixcontrol/order/index.html | 70 +- src/pretix/control/urls.py | 1 + src/pretix/control/views/item.py | 91 +-- src/pretix/presale/checkoutflow.py | 65 +- src/pretix/presale/forms/checkout.py | 7 +- .../event/checkout_questions.html | 12 + .../pretixpresale/event/fragment_cart.html | 2 +- src/pretix/presale/views/__init__.py | 45 +- src/pretix/presale/views/order.py | 6 +- src/pretix/presale/views/questions.py | 6 +- .../js/ui/questionnaires/App.vue | 146 ++++ .../js/ui/questionnaires/I18nTextField.vue | 16 + .../js/ui/questionnaires/NativeDialog.vue | 49 ++ .../js/ui/questionnaires/QuestionElement.vue | 154 ++++ .../questionnaires/QuestionnaireElement.vue | 163 ++++ .../pretixcontrol/js/ui/questionnaires/api.ts | 67 ++ .../js/ui/questionnaires/helper.js | 97 +++ .../js/ui/questionnaires/index.ts | 4 + .../js/ui/questionnaires/model.ts | 65 ++ .../static/pretixcontrol/scss/_forms.scss | 3 + .../static/pretixpresale/widget/TODOS.md | 2 + vite.config.ts | 3 +- 41 files changed, 2042 insertions(+), 842 deletions(-) create mode 100644 src/pretix/base/migrations/0308_questionnaire_questionnairechild.py create mode 100644 src/pretix/control/templates/pretixcontrol/items/questionnaires.html create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/App.vue create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/I18nTextField.vue create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/NativeDialog.vue create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/QuestionElement.vue create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/QuestionnaireElement.vue create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/api.ts create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/helper.js create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/index.ts create mode 100644 src/pretix/static/pretixcontrol/js/ui/questionnaires/model.ts diff --git a/package-lock.json b/package-lock.json index a4cb569db1..f43909f1e8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,8 @@ "version": "1.0.0", "license": "SEE LICENSE IN LICENSE", "dependencies": { - "vue": "^3.5.30" + "vue": "^3.5.30", + "vue-slicksort": "^2.0.5" }, "devDependencies": { "@eslint/js": "^10.0.1", @@ -4635,6 +4636,15 @@ "vue-eslint-parser": "^10.0.0" } }, + "node_modules/vue-slicksort": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/vue-slicksort/-/vue-slicksort-2.0.5.tgz", + "integrity": "sha512-fXz1YrNjhUbJK7o0tMk27mIr4pMAZYLSYvtmLazCtfpvz+zafPCn34ILDL8B7hT7WLVZKreYs6JVe5VWymqmzA==", + "license": "MIT", + "peerDependencies": { + "vue": ">=3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/package.json b/package.json index c20749c4eb..407da1a2d4 100644 --- a/package.json +++ b/package.json @@ -27,7 +27,8 @@ "test": "echo \"Error: no test specified\" && exit 1" }, "dependencies": { - "vue": "^3.5.30" + "vue": "^3.5.30", + "vue-slicksort": "^2.0.5" }, "devDependencies": { "@eslint/js": "^10.0.1", diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 73e0b9c518..7abf31bd7f 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -702,7 +702,7 @@ class TaxRuleSerializer(CountryFieldMixin, I18nAwareModelSerializer): return super().save(**kwargs) def validate_default(self, value): - if not value and self.instance.default: + if not value and (not self.instance or self.instance.default): raise ValidationError("You can't remove the default property, instead set it on another tax rule.") return value diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index dacf056b6a..9bf80d51eb 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -53,6 +53,7 @@ from pretix.base.models import ( ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel, ) +from pretix.base.models.items import Questionnaire, QuestionnaireChild class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer): @@ -542,6 +543,7 @@ class LegacyDependencyValueField(serializers.CharField): class QuestionSerializer(I18nAwareModelSerializer): options = InlineQuestionOptionSerializer(many=True, required=False) identifier = serializers.CharField(allow_null=True) + internal_name = serializers.CharField(allow_null=True, source='question', read_only=True) dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True) class Meta: @@ -550,7 +552,7 @@ class QuestionSerializer(I18nAwareModelSerializer): 'ask_during_checkin', 'show_during_checkin', 'identifier', 'dependency_question', 'dependency_values', 'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min', 'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max', - 'valid_string_length_max', 'valid_file_portrait') + 'valid_string_length_max', 'valid_file_portrait', 'internal_name',) def validate_identifier(self, value): Question._clean_identifier(self.context['event'], value, self.instance) @@ -626,6 +628,160 @@ class QuestionSerializer(I18nAwareModelSerializer): return question +class QuestionRefField(serializers.PrimaryKeyRelatedField): + def to_representation(self, qc): + if not qc: + return None + elif qc.system_question: + return qc.system_question + elif qc.user_question_id: + return qc.user_question_id + else: + return None + + def to_internal_value(self, data): + if type(data) == int: + return {'user_question': super().to_internal_value(data), 'system_question': None} + elif type(data) == str or data is None: + return {'user_question': None, 'system_question': data} + else: + self.fail('incorrect_type', data_type=type(data).__name__) + + def use_pk_only_optimization(self): + return self.source == '*' + + +class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer): + question = QuestionRefField(source='*', queryset=Question.objects.none()) + dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none()) + + class Meta: + model = QuestionnaireChild + fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values') + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.fields["question"].queryset = self.context["event"].questions.all() + self.fields["dependency_question"].queryset = self.context["event"].questions.all() + + def validate(self, data): + data = super().validate(data) + event = self.context['event'] + + 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: + if dep.ask_during_checkin: + raise ValidationError(_('Question cannot depend on a question asked during check-in.')) + + 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 data + + 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 + + +class QuestionnaireSerializer(I18nAwareModelSerializer): + limit_sales_channels = serializers.SlugRelatedField( + slug_field="identifier", + queryset=SalesChannel.objects.none(), + required=False, + allow_empty=True, + many=True, + ) + + class Meta: + model = Questionnaire + fields = ('id', 'type', 'internal_name', 'items', 'position', 'all_sales_channels', 'limit_sales_channels', 'children') + + def __init__(self, *args, **kwargs): + self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False) + super().__init__(*args, **kwargs) + + def validate(self, data): + data = super().validate(data) + event = self.context['event'] + + #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.') + + #if full_data.get('ask_during_checkin') and full_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED: + # raise ValidationError(_('This type of question cannot be asked during check-in.')) + + #if full_data.get('show_during_checkin') and full_data.get('type') in Question.SHOW_DURING_CHECKIN_UNSUPPORTED: + # raise ValidationError(_('This type of question cannot be shown during check-in.')) + + #Question.clean_items(event, full_data.get('items') or []) + return data + + def validate_children(self, value): + prev_questions = {} + for child in value: + if child.get('dependency_question'): + if (child['dependency_question']['user_question'] or child['dependency_question']['system_question']) not in prev_questions: + raise ValidationError('A question can only depend on a previous question from the same questionnaire.') + + if child['user_question']: + prev_questions[child['user_question']] = child + if child['system_question']: + prev_questions[child['system_question']] = child + return value + + @transaction.atomic + def create(self, validated_data): + children_data = validated_data.pop('children') if 'children' in validated_data else [] + questionnaire = super().create(validated_data) + self.set_children(questionnaire, children_data) + return questionnaire + + @transaction.atomic + def update(self, instance, validated_data): + children_data = validated_data.pop('children', None) + questionnaire = super().update(instance, validated_data) + if children_data is not None: + self.set_children(questionnaire, children_data) + return questionnaire + + def set_children(self, questionnaire, new_data): + result = [] + child_serializer = self.fields['children'].child + existing = questionnaire.children.all() + for i, d in enumerate(new_data): + d['questionnaire'] = questionnaire + d['position'] = i + 1 + d.setdefault('required', False) + d.setdefault('help_text', None) + d.setdefault('dependency_question', None) + d.setdefault('dependency_values', None) + updatable = min(len(existing), len(new_data)) + for i in range(0, updatable): + result.append(child_serializer.update(existing[i], new_data[i])) + for i in range(updatable, len(new_data)): + result.append(child_serializer.create(new_data[i])) + for i in range(updatable, len(existing)): + existing[i].delete() + return result + + class QuotaSerializer(I18nAwareModelSerializer): available = serializers.BooleanField(read_only=True) available_number = serializers.IntegerField(read_only=True) diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index 911ce8c9e0..6cd73c3bf8 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -79,7 +79,8 @@ event_router.register(r'subevents', event.SubEventViewSet) event_router.register(r'clone', event.CloneEventViewSet) event_router.register(r'items', item.ItemViewSet) event_router.register(r'categories', item.ItemCategoryViewSet) -event_router.register(r'questions', item.QuestionViewSet) +event_router.register(r'datafields', item.QuestionViewSet) +event_router.register(r'questionnaires', item.QuestionnaireViewSet) event_router.register(r'discounts', discount.DiscountViewSet) event_router.register(r'quotas', item.QuotaViewSet) event_router.register(r'vouchers', voucher.VoucherViewSet) diff --git a/src/pretix/api/views/item.py b/src/pretix/api/views/item.py index 8250600a0a..cf529a5e08 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -48,13 +48,14 @@ from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.item import ( ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer, ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer, - QuestionOptionSerializer, QuestionSerializer, QuotaSerializer, + QuestionOptionSerializer, QuestionSerializer, QuestionnaireSerializer, QuotaSerializer, ) from pretix.api.views import ConditionalListView from pretix.base.models import ( CartPosition, Item, ItemAddOn, ItemBundle, ItemCategory, ItemProgramTime, ItemVariation, Question, QuestionOption, Quota, ) +from pretix.base.models.items import Questionnaire from pretix.base.services.quotas import QuotaAvailability from pretix.helpers.dicts import merge_dicts from pretix.helpers.i18n import i18ncomp @@ -563,6 +564,51 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): super().perform_destroy(instance) +class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet): + serializer_class = QuestionnaireSerializer + queryset = Questionnaire.objects.none() + #filter_backends = (DjangoFilterBackend, TotalOrderingFilter) + #filterset_class = QuestionFilter + ordering_fields = ('id', 'position') + ordering = ('position', 'id') + permission = None + write_permission = 'event.items:write' + + def get_queryset(self): + return self.request.event.questionnaires.prefetch_related('children').all() + + def perform_create(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.questionnaire.added', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + + def get_serializer_context(self): + ctx = super().get_serializer_context() + ctx['event'] = self.request.event + return ctx + + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.questionnaire.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + + def perform_destroy(self, instance): + instance.log_action( + 'pretix.event.questionnaire.deleted', + user=self.request.user, + auth=self.request.auth, + ) + super().perform_destroy(instance) + + class NumberInFilter(django_filters.BaseInFilter, django_filters.NumberFilter): pass diff --git a/src/pretix/base/forms/questions.py b/src/pretix/base/forms/questions.py index df0f158ed5..6c605428a8 100644 --- a/src/pretix/base/forms/questions.py +++ b/src/pretix/base/forms/questions.py @@ -656,7 +656,7 @@ class BaseQuestionsForm(forms.Form): orderpos = self.orderpos = kwargs.pop('orderpos', None) pos = cartpos or orderpos item = pos.item - questions = pos.item.questions_to_ask + questionnaires = pos.item.relevant_questionnaires event = kwargs.pop('event') self.all_optional = kwargs.pop('all_optional', False) self.attendee_addresses_required = event.settings.attendee_addresses_required and not self.all_optional @@ -664,384 +664,15 @@ class BaseQuestionsForm(forms.Form): super().__init__(*args, **kwargs) if cartpos and item.validity_mode == Item.VALIDITY_MODE_DYNAMIC and item.validity_dynamic_start_choice: - if item.validity_dynamic_start_choice_day_limit: - max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit) - else: - max_date = None - min_date = time_machine_now() - initial = None - if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership: - if pos.used_membership.date_start >= time_machine_now(): - initial = min_date = pos.used_membership.date_start - max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end - if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days: - attrs = {} - if max_date: - attrs['data-max'] = max_date.date().isoformat() - if min_date: - attrs['data-min'] = min_date.date().isoformat() - self.fields['requested_valid_from'] = forms.DateField( - label=_('Start date'), - help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'), - required=bool(initial), - initial=pos.requested_valid_from or initial, - widget=DatePickerWidget(attrs), - validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())] - ) - else: - self.fields['requested_valid_from'] = forms.SplitDateTimeField( - label=_('Start date'), - help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'), - required=bool(initial), - initial=pos.requested_valid_from or initial, - widget=SplitDateTimePickerWidget( - time_format=get_format_without_seconds('TIME_INPUT_FORMATS'), - min_date=min_date, - max_date=max_date - ), - validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)] - ) + self.fields['requested_valid_from'] = self.build_requested_valid_from_field(event, pos, item) - add_fields = {} - - if item.ask_attendee_data and event.settings.attendee_names_asked: - add_fields['attendee_name_parts'] = NamePartsFormField( - max_length=255, - required=event.settings.attendee_names_required and not self.all_optional, - scheme=event.settings.name_scheme, - titles=event.settings.name_scheme_titles, - label=_('Attendee name'), - initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts), - ) - if item.ask_attendee_data and event.settings.attendee_emails_asked: - add_fields['attendee_email'] = forms.EmailField( - required=event.settings.attendee_emails_required and not self.all_optional, - label=_('Attendee email'), - initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email), - widget=forms.EmailInput( - attrs={ - 'autocomplete': 'email' - } - ) - ) - if item.ask_attendee_data and event.settings.attendee_company_asked: - add_fields['company'] = forms.CharField( - required=event.settings.attendee_company_required and not self.all_optional, - label=_('Company'), - max_length=255, - initial=(cartpos.company if cartpos else orderpos.company), - ) - - if item.ask_attendee_data and event.settings.attendee_addresses_asked: - add_fields['street'] = forms.CharField( - required=self.attendee_addresses_required, - label=_('Address'), - widget=forms.Textarea(attrs={ - 'rows': 2, - 'placeholder': _('Street and Number'), - 'autocomplete': 'street-address' - }), - initial=(cartpos.street if cartpos else orderpos.street), - ) - add_fields['zipcode'] = forms.CharField( - required=False, - max_length=30, - label=_('ZIP code'), - initial=(cartpos.zipcode if cartpos else orderpos.zipcode), - widget=forms.TextInput(attrs={ - 'autocomplete': 'postal-code', - }), - ) - add_fields['city'] = forms.CharField( - required=False, - label=_('City'), - max_length=255, - initial=(cartpos.city if cartpos else orderpos.city), - widget=forms.TextInput(attrs={ - 'autocomplete': 'address-level2', - }), - ) - country = (cartpos.country if cartpos else orderpos.country) or guess_country_from_request(request, event) - add_fields['country'] = CountryField( - countries=CachedCountries - ).formfield( - required=self.attendee_addresses_required, - label=_('Country'), - initial=country, - widget=forms.Select(attrs={ - 'autocomplete': 'country', - 'data-trigger-address-info': 'on', - }), - ) - c = [('', '---')] - fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else '' - cc = None - state = None - if fprefix + 'country' in self.data: - cc = str(self.data[fprefix + 'country']) - elif country: - cc = str(country) - if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS: - types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] - statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] - c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1]) - state = (cartpos.state if cartpos else orderpos.state) - elif fprefix + 'state' in self.data: - self.data = self.data.copy() - del self.data[fprefix + 'state'] - - add_fields['state'] = forms.ChoiceField( - label=pgettext_lazy('address', 'State'), - required=False, - choices=c, - initial=state, - widget=forms.Select(attrs={ - 'autocomplete': 'address-level1', - }), - ) - add_fields['state'].widget.is_required = True - - field_positions = list( - [ - (n, event.settings.system_question_order.get(n if n != 'state' else 'country', 0)) - for n in add_fields.keys() - ] - ) - - for q in questions: - # Do we already have an answer? Provide it as the initial value - answers = [a for a in pos.answerlist if a.question_id == q.id] - if answers: - initial = answers[0] - else: - initial = None - tz = ZoneInfo(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 required: - # For some reason, django-bootstrap3 does not set the required attribute - # itself. - widget = forms.CheckboxInput(attrs={'required': 'required'}) - else: - widget = forms.CheckboxInput() - - if initial: - initialbool = (initial.answer == "True") - else: - initialbool = False - - field = forms.BooleanField( - label=label, required=required, - help_text=help_text, - initial=initialbool, widget=widget, - ) - elif q.type == Question.TYPE_NUMBER: - field = forms.DecimalField( - label=label, required=required, - min_value=q.valid_number_min or Decimal('0.00'), - max_value=q.valid_number_max, - help_text=help_text, - initial=initial.answer if initial else None, - ) - elif q.type == Question.TYPE_STRING: - field = forms.CharField( - label=label, required=required, - max_length=q.valid_string_length_max, - help_text=help_text, - initial=initial.answer if initial else None, - ) - elif q.type == Question.TYPE_TEXT: - field = forms.CharField( - label=label, required=required, - max_length=q.valid_string_length_max, - help_text=help_text, - widget=forms.Textarea, - initial=initial.answer if initial else None, - ) - elif q.type == Question.TYPE_COUNTRYCODE: - field = CountryField( - countries=CachedCountries, - blank=True, null=True, blank_label=' ', - ).formfield( - label=label, required=required, - help_text=help_text, - widget=forms.Select, - empty_label=' ', - initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None), - ) - elif q.type == Question.TYPE_CHOICE: - field = forms.ModelChoiceField( - queryset=q.options, - 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=required, - help_text=help_text, - to_field_name='identifier', - widget=QuestionCheckboxSelectMultiple, - initial=initial.options.all() if initial else None, - ) - elif q.type == Question.TYPE_FILE: - if q.valid_file_portrait: - field = PortraitImageField( - label=label, required=required, - help_text=help_text, - initial=initial.file if initial else None, - widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}), - ) - else: - field = ExtFileField( - label=label, required=required, - help_text=help_text, - initial=initial.file if initial else None, - widget=UploadedFileWidget(position=pos, event=event, answer=initial), - ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER, - max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER, - ) - elif q.type == Question.TYPE_DATE: - attrs = {} - if q.valid_date_min: - attrs['data-min'] = q.valid_date_min.isoformat() - if q.valid_date_max: - attrs['data-max'] = q.valid_date_max.isoformat() - if not help_text: - if q.valid_date_min and q.valid_date_max: - help_text = format_lazy( - _('Please enter a date between {min} and {max}.'), - min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), - max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), - ) - elif q.valid_date_min: - help_text = format_lazy( - _('Please enter a date no earlier than {min}.'), - min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), - ) - elif q.valid_date_max: - help_text = format_lazy( - _('Please enter a date no later than {max}.'), - max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), - ) - if initial and initial.answer: - try: - _initial = dateutil.parser.parse(initial.answer).date() - except dateutil.parser.ParserError: - _initial = None - else: - _initial = None - field = forms.DateField( - label=label, required=required, - help_text=help_text, - initial=_initial, - widget=DatePickerWidget(attrs), - ) - if q.valid_date_min: - field.validators.append(MinDateValidator(q.valid_date_min)) - if q.valid_date_max: - field.validators.append(MaxDateValidator(q.valid_date_max)) - elif q.type == Question.TYPE_TIME: - if initial and initial.answer: - try: - _initial = dateutil.parser.parse(initial.answer).time() - except dateutil.parser.ParserError: - _initial = None - else: - _initial = None - field = forms.TimeField( - label=label, required=required, - help_text=help_text, - initial=_initial, - widget=TimePickerWidget(without_seconds=True), - ) - elif q.type == Question.TYPE_DATETIME: - if not help_text: - if q.valid_datetime_min and q.valid_datetime_max: - help_text = format_lazy( - _('Please enter a date and time between {min} and {max}.'), - min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), - max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), - ) - elif q.valid_datetime_min: - help_text = format_lazy( - _('Please enter a date and time no earlier than {min}.'), - min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), - ) - elif q.valid_datetime_max: - help_text = format_lazy( - _('Please enter a date and time no later than {max}.'), - max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), - ) - - if initial and initial.answer: - try: - _initial = dateutil.parser.parse(initial.answer).astimezone(tz) - except dateutil.parser.ParserError: - _initial = None - else: - _initial = None - - field = SplitDateTimeField( - label=label, required=required, - help_text=help_text, - initial=_initial, - widget=SplitDateTimePickerWidget( - time_format=get_format_without_seconds('TIME_INPUT_FORMATS'), - min_date=q.valid_datetime_min, - max_date=q.valid_datetime_max - ), - ) - if q.valid_datetime_min: - field.validators.append(MinDateTimeValidator(q.valid_datetime_min)) - if q.valid_datetime_max: - field.validators.append(MaxDateTimeValidator(q.valid_datetime_max)) - elif q.type == Question.TYPE_PHONENUMBER: - if initial: - try: - initial = PhoneNumber().from_string(initial.answer) - except NumberParseException: - initial = None - - if not initial: - phone_prefix = guess_phone_prefix_from_request(request, event) - if phone_prefix: - initial = "+{}.".format(phone_prefix) - - field = PhoneNumberField( - label=label, required=required, - help_text=help_text, - # We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just - # a country code but no number as an initial value. It's a bit hacky, but should be stable for - # the future. - initial=initial, - widget=WrappedPhoneNumberPrefixWidget() - ) - field.question = q - 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-values'] = escapejson_attr(json.dumps(q.dependency_values)) - 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 - - add_fields['question_%s' % q.id] = field - field_positions.append(('question_%s' % q.id, q.position)) - - field_positions.sort(key=lambda e: e[1]) - for fname, p in field_positions: - self.fields[fname] = add_fields[fname] + for questionnaire in questionnaires: + for child in getattr(questionnaire, 'childlist', questionnaire.children.all()): + if child.user_question: + q = child.user_question + self.fields['question_%s' % q.id] = self.build_user_question_field(request, event, pos, q) + elif child.system_question: + self.fields[child.system_question] = self.build_system_question_field(request, event, pos, child) responses = question_form_fields.send(sender=event, position=pos) data = pos.meta_info_data @@ -1066,6 +697,388 @@ class BaseQuestionsForm(forms.Form): else: v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + autocomplete + def build_requested_valid_from_field(self, event, pos, item): + if item.validity_dynamic_start_choice_day_limit: + max_date = time_machine_now().astimezone(event.timezone) + timedelta(days=item.validity_dynamic_start_choice_day_limit) + else: + max_date = None + min_date = time_machine_now() + initial = None + if (item.require_membership or (pos.variation and pos.variation.require_membership)) and pos.used_membership: + if pos.used_membership.date_start >= time_machine_now(): + initial = min_date = pos.used_membership.date_start + max_date = min(max_date, pos.used_membership.date_end) if max_date else pos.used_membership.date_end + if item.validity_dynamic_duration_months or item.validity_dynamic_duration_days: + attrs = {} + if max_date: + attrs['data-max'] = max_date.date().isoformat() + if min_date: + attrs['data-min'] = min_date.date().isoformat() + return forms.DateField( + label=_('Start date'), + help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'), + required=bool(initial), + initial=pos.requested_valid_from or initial, + widget=DatePickerWidget(attrs), + validators=([MaxDateValidator(max_date.date())] if max_date else []) + [MinDateValidator(min_date.date())] + ) + else: + return forms.SplitDateTimeField( + label=_('Start date'), + help_text='' if initial else _('If you keep this empty, the ticket will be valid starting at the time of purchase.'), + required=bool(initial), + initial=pos.requested_valid_from or initial, + widget=SplitDateTimePickerWidget( + time_format=get_format_without_seconds('TIME_INPUT_FORMATS'), + min_date=min_date, + max_date=max_date + ), + validators=([MaxDateTimeValidator(max_date)] if max_date else []) + [MinDateTimeValidator(min_date)] + ) + + def build_system_question_field(self, request, event, pos, qc): + field_name = qc.system_question + if field_name == 'attendee_name_parts': + return NamePartsFormField( + max_length=255, + required=qc.required and not self.all_optional, + scheme=event.settings.name_scheme, + titles=event.settings.name_scheme_titles, + label=qc.label, + help_text=qc.help_text, + initial=pos.attendee_name_parts, + ) + if field_name == 'attendee_email': + return forms.EmailField( + required=qc.required and not self.all_optional, + label=qc.label, + help_text=qc.help_text, + initial=pos.attendee_email, + widget=forms.EmailInput( + attrs={ + 'autocomplete': 'email' + } + ) + ) + if field_name == 'company': + return forms.CharField( + required=qc.required and not self.all_optional, + label=qc.label, + help_text=qc.help_text, + max_length=255, + initial=pos.company, + ) + + if field_name == 'street': + return forms.CharField( + required=qc.required and not self.all_optional, + label=qc.label, + help_text=qc.help_text, + widget=forms.Textarea(attrs={ + 'rows': 2, + 'placeholder': _('Street and Number'), + 'autocomplete': 'street-address' + }), + initial=pos.street, + ) + if field_name == 'zipcode': + return forms.CharField( + required=False, + max_length=30, + label=qc.label, + help_text=qc.help_text, + initial=pos.zipcode, + widget=forms.TextInput(attrs={ + 'autocomplete': 'postal-code', + }), + ) + if field_name == 'city': + return forms.CharField( + required=False, + label=qc.label, + help_text=qc.help_text, + max_length=255, + initial=pos.city, + widget=forms.TextInput(attrs={ + 'autocomplete': 'address-level2', + }), + ) + if field_name == 'country': + country = pos.country or guess_country_from_request(request, event) + return CountryField( + countries=CachedCountries + ).formfield( + required=qc.required and not self.all_optional, + label=qc.label, + help_text=qc.help_text, + initial=country, + widget=forms.Select(attrs={ + 'autocomplete': 'country', + 'data-trigger-address-info': 'on', + }), + ) + if field_name == 'state': + country = pos.country or guess_country_from_request(request, event) + c = [('', '---')] + fprefix = str(self.prefix) + '-' if self.prefix is not None and self.prefix != '-' else '' + cc = None + state = None + if fprefix + 'country' in self.data: + cc = str(self.data[fprefix + 'country']) + elif country: + cc = str(country) + if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS: + types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc] + statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] + c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1]) + state = (pos.state) + elif fprefix + 'state' in self.data: + self.data = self.data.copy() + del self.data[fprefix + 'state'] + + field = forms.ChoiceField( + label=qc.label, + help_text=qc.help_text, + required=False, + choices=c, + initial=state, + widget=forms.Select(attrs={ + 'autocomplete': 'address-level1', + }), + ) + field.widget.is_required = True + return field + + def build_user_question_field(self, request, event, pos, q): + # Do we already have an answer? Provide it as the initial value + answers = [a for a in pos.answerlist if a.question_id == q.id] + if answers: + initial = answers[0] + else: + initial = None + tz = ZoneInfo(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 required: + # For some reason, django-bootstrap3 does not set the required attribute + # itself. + widget = forms.CheckboxInput(attrs={'required': 'required'}) + else: + widget = forms.CheckboxInput() + + if initial: + initialbool = (initial.answer == "True") + else: + initialbool = False + + field = forms.BooleanField( + label=label, required=required, + help_text=help_text, + initial=initialbool, widget=widget, + ) + elif q.type == Question.TYPE_NUMBER: + field = forms.DecimalField( + label=label, required=required, + min_value=q.valid_number_min or Decimal('0.00'), + max_value=q.valid_number_max, + help_text=help_text, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_STRING: + field = forms.CharField( + label=label, required=required, + max_length=q.valid_string_length_max, + help_text=help_text, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_TEXT: + field = forms.CharField( + label=label, required=required, + max_length=q.valid_string_length_max, + help_text=help_text, + widget=forms.Textarea, + initial=initial.answer if initial else None, + ) + elif q.type == Question.TYPE_COUNTRYCODE: + field = CountryField( + countries=CachedCountries, + blank=True, null=True, blank_label=' ', + ).formfield( + label=label, required=required, + help_text=help_text, + widget=forms.Select, + empty_label=' ', + initial=initial.answer if initial else (guess_country_from_request(request, event) if required else None), + ) + elif q.type == Question.TYPE_CHOICE: + field = forms.ModelChoiceField( + queryset=q.options, + 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=required, + help_text=help_text, + to_field_name='identifier', + widget=QuestionCheckboxSelectMultiple, + initial=initial.options.all() if initial else None, + ) + elif q.type == Question.TYPE_FILE: + if q.valid_file_portrait: + field = PortraitImageField( + label=label, required=required, + help_text=help_text, + initial=initial.file if initial else None, + widget=PortraitImageWidget(position=pos, event=event, answer=initial, attrs={'data-portrait-photo': 'true'}), + ) + else: + field = ExtFileField( + label=label, required=required, + help_text=help_text, + initial=initial.file if initial else None, + widget=UploadedFileWidget(position=pos, event=event, answer=initial), + ext_whitelist=settings.FILE_UPLOAD_EXTENSIONS_OTHER, + max_size=settings.FILE_UPLOAD_MAX_SIZE_OTHER, + ) + elif q.type == Question.TYPE_DATE: + attrs = {} + if q.valid_date_min: + attrs['data-min'] = q.valid_date_min.isoformat() + if q.valid_date_max: + attrs['data-max'] = q.valid_date_max.isoformat() + if not help_text: + if q.valid_date_min and q.valid_date_max: + help_text = format_lazy( + _('Please enter a date between {min} and {max}.'), + min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), + max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), + ) + elif q.valid_date_min: + help_text = format_lazy( + _('Please enter a date no earlier than {min}.'), + min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), + ) + elif q.valid_date_max: + help_text = format_lazy( + _('Please enter a date no later than {max}.'), + max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), + ) + if initial and initial.answer: + try: + _initial = dateutil.parser.parse(initial.answer).date() + except dateutil.parser.ParserError: + _initial = None + else: + _initial = None + field = forms.DateField( + label=label, required=required, + help_text=help_text, + initial=_initial, + widget=DatePickerWidget(attrs), + ) + if q.valid_date_min: + field.validators.append(MinDateValidator(q.valid_date_min)) + if q.valid_date_max: + field.validators.append(MaxDateValidator(q.valid_date_max)) + elif q.type == Question.TYPE_TIME: + if initial and initial.answer: + try: + _initial = dateutil.parser.parse(initial.answer).time() + except dateutil.parser.ParserError: + _initial = None + else: + _initial = None + field = forms.TimeField( + label=label, required=required, + help_text=help_text, + initial=_initial, + widget=TimePickerWidget(without_seconds=True), + ) + elif q.type == Question.TYPE_DATETIME: + if not help_text: + if q.valid_datetime_min and q.valid_datetime_max: + help_text = format_lazy( + _('Please enter a date and time between {min} and {max}.'), + min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), + max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), + ) + elif q.valid_datetime_min: + help_text = format_lazy( + _('Please enter a date and time no earlier than {min}.'), + min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), + ) + elif q.valid_datetime_max: + help_text = format_lazy( + _('Please enter a date and time no later than {max}.'), + max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), + ) + + if initial and initial.answer: + try: + _initial = dateutil.parser.parse(initial.answer).astimezone(tz) + except dateutil.parser.ParserError: + _initial = None + else: + _initial = None + + field = SplitDateTimeField( + label=label, required=required, + help_text=help_text, + initial=_initial, + widget=SplitDateTimePickerWidget( + time_format=get_format_without_seconds('TIME_INPUT_FORMATS'), + min_date=q.valid_datetime_min, + max_date=q.valid_datetime_max + ), + ) + if q.valid_datetime_min: + field.validators.append(MinDateTimeValidator(q.valid_datetime_min)) + if q.valid_datetime_max: + field.validators.append(MaxDateTimeValidator(q.valid_datetime_max)) + elif q.type == Question.TYPE_PHONENUMBER: + if initial: + try: + initial = PhoneNumber().from_string(initial.answer) + except NumberParseException: + initial = None + + if not initial: + phone_prefix = guess_phone_prefix_from_request(request, event) + if phone_prefix: + initial = "+{}.".format(phone_prefix) + + field = PhoneNumberField( + label=label, required=required, + help_text=help_text, + # We now exploit an implementation detail in PhoneNumberPrefixWidget to allow us to pass just + # a country code but no number as an initial value. It's a bit hacky, but should be stable for + # the future. + initial=initial, + widget=WrappedPhoneNumberPrefixWidget() + ) + field.question = q + 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-values'] = escapejson_attr(json.dumps(q.dependency_values)) + 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 + + return field + def clean(self): from pretix.base.addressvalidation import \ validate_address # local import to prevent impact on startup time diff --git a/src/pretix/base/migrations/0308_questionnaire_questionnairechild.py b/src/pretix/base/migrations/0308_questionnaire_questionnairechild.py new file mode 100644 index 0000000000..2362dc8db6 --- /dev/null +++ b/src/pretix/base/migrations/0308_questionnaire_questionnairechild.py @@ -0,0 +1,175 @@ +# Generated by Django 4.2.29 on 2026-03-19 14:24 +import json +from collections import namedtuple +from itertools import chain, groupby + +from django.db import migrations, models +import django.db.models.deletion +import i18nfield.fields +from i18nfield.strings import LazyI18nString + +import pretix.base.models.base +import pretix.base.models.fields + + +FakeQuestion = namedtuple( + 'FakeQuestion', 'id question position required' +) + + +def get_fake_questions(settings): + def b(s): + return s == 'True' + fq = [] + sqo = json.loads(settings.get('system_question_order', '{}')) + _ = LazyI18nString.from_gettext + + if b(settings.get('attendee_names_asked', 'True')): + fq.append(FakeQuestion('attendee_name_parts', _('Attendee name'), sqo.get('attendee_name_parts', 0), b(settings.get('attendee_names_required')))) + + if b(settings.get('attendee_emails_asked')): + fq.append(FakeQuestion('attendee_email', _('Attendee email'), sqo.get('attendee_email', 0), b(settings.get('attendee_emails_required')))) + + if b(settings.get('attendee_company_asked')): + fq.append(FakeQuestion('company', _('Company'), sqo.get('company', 0), b(settings.get('attendee_company_required')))) + + if b(settings.get('attendee_addresses_asked')): + fq.append(FakeQuestion('street', _('Street'), sqo.get('street', 0), b(settings.get('attendee_addresses_required')))) + fq.append(FakeQuestion('zipcode', _('ZIP code'), sqo.get('zipcode', 0), b(settings.get('attendee_addresses_required')))) + fq.append(FakeQuestion('city', _('City'), sqo.get('city', 0), b(settings.get('attendee_addresses_required')))) + fq.append(FakeQuestion('country', _('Country'), sqo.get('country', 0), b(settings.get('attendee_addresses_required')))) + return fq + + +def migrate_questions_forward(apps, schema_editor): + Event = apps.get_model("pretixbase", "Event") + Item = apps.get_model("pretixbase", "Item") + Question = apps.get_model("pretixbase", "Question") + Questionnaire = apps.get_model("pretixbase", "Questionnaire") + QuestionnaireChild = apps.get_model("pretixbase", "QuestionnaireChild") + EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore') + + def create_grouped_item_questionnaires(event, children, label_prefix, questionnaire_type): + # group by item, creating a unique questionnaire per item + item_questionnaires = (([t[3] for t in children], item_id) for item_id, children in + groupby(children, key=lambda t: t[0])) + + # group again, merging all questionnaires with identical children + merged_questionnaires = groupby(sorted(item_questionnaires, key=lambda t: [q.id for q in t[0]]), + key=lambda t: t[0]) + for children, iterator in merged_questionnaires: + items = [item for _c, item in iterator] + + # create questionnaires and children + questionnaire = Questionnaire.objects.create( + event=event, type=questionnaire_type, position=0, all_sales_channels=True, + internal_name=label_prefix + ', '.join(str(iname or name) for (id, iname, name) in items) + ) + questionnaire.items.set([id for (id, iname, name) in items]) + deps = {} + for position, child in enumerate(children): + if isinstance(child, FakeQuestion): + QuestionnaireChild.objects.create( + questionnaire=questionnaire, + position=position + 1, + system_question=child.id, + required=child.required, + label=child.question, + ) + else: + deps[child.id] = QuestionnaireChild.objects.create( + questionnaire=questionnaire, + position=position + 1, + user_question=child, + required=child.required, + label=child.question, + help_text=child.help_text, + dependency_question=deps[child.dependency_question.id] if child.dependency_question else None, + dependency_values=child.dependency_values, + ) + + for event in Event.objects.iterator(): + # get relevant settings + settings = { + setting.key: setting.value for setting in EventSettingsStore.objects.filter(object_id=event.id, key__in=( + 'system_question_order', 'attendee_names_asked', 'attendee_names_required', 'attendee_emails_asked', 'attendee_emails_required', + 'attendee_company_asked', 'attendee_company_required', 'attendee_addresses_asked', 'attendee_addresses_required', + )) + } + + # get all questions (user-defined and system provided), along with the products for which they're asked + questions = event.questions.all() + children = sorted(chain(( + (item, q.position, q.id, q) + for q in questions.filter(hidden=False) + for item in q.items.values_list('id', 'internal_name', 'name') + ), ( + (item, q.position, 0, q) + for q in get_fake_questions(settings) + for item in event.items.filter(personalized=True).values_list('id', 'internal_name', 'name') + )), key=lambda t: (t[0], t[1], t[2])) + + create_grouped_item_questionnaires(event, children, '', 'PS') + + children = sorted(chain(( + (item, q.position, q.id, q) + for q in questions.filter(hidden=True) + for item in q.items.values_list('id', 'internal_name', 'name') + )), key=lambda t: (t[0], t[1], t[2])) + + create_grouped_item_questionnaires(event, children, 'Hidden questions for ', 'PH') + + +def migrate_questions_backward(apps, schema_editor): + pass # as long as we don't delete the old columns, this is a no op. after that, it gets complicated... + + +class Migration(migrations.Migration): + + dependencies = [ + ('pretixbase', '0307_devicelastseen'), + ] + + operations = [ + migrations.CreateModel( + name='Questionnaire', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('internal_name', models.CharField(max_length=255)), + ('type', models.CharField(max_length=5)), + ('position', models.PositiveIntegerField(default=0)), + ('all_sales_channels', models.BooleanField(default=True)), + ('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='questionnaires', to='pretixbase.event')), + ('items', models.ManyToManyField(related_name='questionnaires', to='pretixbase.item')), + ('limit_sales_channels', models.ManyToManyField(to='pretixbase.saleschannel')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.CreateModel( + name='QuestionnaireChild', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False)), + ('position', models.PositiveIntegerField(default=0)), + ('system_question', models.CharField(max_length=25, null=True)), + ('required', models.BooleanField(default=False)), + ('label', i18nfield.fields.I18nTextField()), + ('help_text', i18nfield.fields.I18nTextField(null=True)), + ('dependency_values', pretix.base.models.fields.MultiStringField(default=[])), + ('dependency_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='dependent_questions', to='pretixbase.questionnairechild')), + ('questionnaire', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='children', to='pretixbase.questionnaire')), + ('user_question', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='references', to='pretixbase.question')), + ], + options={ + 'abstract': False, + }, + bases=(models.Model, pretix.base.models.base.LoggingMixin), + ), + migrations.RunPython( + migrate_questions_forward, + migrate_questions_backward, + ), + # TODO remove old columns from Question model + ] diff --git a/src/pretix/base/models/__init__.py b/src/pretix/base/models/__init__.py index 3657cfb4c2..5b965574ee 100644 --- a/src/pretix/base/models/__init__.py +++ b/src/pretix/base/models/__init__.py @@ -37,7 +37,7 @@ from .invoices import Invoice, InvoiceLine, invoice_filename from .items import ( Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue, ItemProgramTime, ItemVariation, ItemVariationMetaValue, Question, - QuestionOption, Quota, SubEventItem, SubEventItemVariation, + QuestionOption, Questionnaire, QuestionnaireChild, Quota, SubEventItem, SubEventItemVariation, itempicture_upload_to, ) from .log import LogEntry diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 272a263976..85920d3f1e 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1569,10 +1569,12 @@ class ItemBundle(models.Model): class Question(LoggedModel): """ - A question is an input field that can be used to extend a ticket by custom information, - e.g. "Attendee age". The answers are found next to the position. The answers may be found - in QuestionAnswers, attached to OrderPositions/CartPositions. A question can allow one of - several input types, currently: + A question is a data field that can be used to extend an order or a ticket by custom + information, e.g. "Attendee age". To be actually useful, questions need to be added to + one or multiple Questionnaires. The answers may be found in QuestionAnswers, attached + to Orders, OrderPositions or CartPositions. + + A question can allow one of several input types, currently: * a number (``TYPE_NUMBER``) * a one-line string (``TYPE_STRING``) @@ -1641,7 +1643,7 @@ class Question(LoggedModel): related_name="questions", on_delete=models.CASCADE ) - question = I18nTextField( + question = I18nTextField( # to be renamed to 'internal_name' verbose_name=_("Question") ) identifier = models.CharField( @@ -1656,7 +1658,7 @@ class Question(LoggedModel): ), ], ) - help_text = I18nTextField( + help_text = I18nTextField( # to be removed verbose_name=_("Help text"), help_text=_("If the question needs to be explained or clarified, do it here!"), null=True, blank=True, @@ -1666,22 +1668,22 @@ class Question(LoggedModel): choices=TYPE_CHOICES, verbose_name=_("Question type") ) - required = models.BooleanField( + required = models.BooleanField( # to be removed, -> QuestionnaireChild default=False, verbose_name=_("Required question") ) - items = models.ManyToManyField( + items = models.ManyToManyField( # to be removed, -> Questionnaire Item, related_name='questions', verbose_name=_("Products"), blank=True, help_text=_('This question will be asked to buyers of the selected products') ) - position = models.PositiveIntegerField( + position = models.PositiveIntegerField( # to be removed, -> Questionnaire + QuestionnaireChild default=0, verbose_name=_("Position") ) - ask_during_checkin = models.BooleanField( + ask_during_checkin = models.BooleanField( # to be removed verbose_name=_('Ask during check-in instead of in the ticket buying process'), help_text=_('Not supported by all check-in apps for all question types.'), default=False @@ -1691,7 +1693,7 @@ class Question(LoggedModel): help_text=_('Not supported by all check-in apps for all question types.'), default=False ) - hidden = models.BooleanField( + hidden = models.BooleanField( # to be removed verbose_name=_('Hidden question'), help_text=_('This question will only show up in the backend.'), default=False @@ -1700,10 +1702,10 @@ class Question(LoggedModel): verbose_name=_('Print answer on invoices'), default=False ) - dependency_question = models.ForeignKey( + dependency_question = models.ForeignKey( # to be removed, -> QuestionnaireChild 'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions' ) - dependency_values = MultiStringField(default=[]) + dependency_values = MultiStringField(default=[]) # to be removed, -> QuestionnaireChild valid_number_min = models.DecimalField(decimal_places=6, max_digits=30, null=True, blank=True, verbose_name=_('Minimum value'), help_text=_('Currently not supported in our apps and during check-in')) @@ -1737,9 +1739,9 @@ class Question(LoggedModel): objects = ScopedManager(organizer='event__organizer') class Meta: - verbose_name = _("Question") - verbose_name_plural = _("Questions") - ordering = ('position', 'id') + verbose_name = _("Data field") + verbose_name_plural = _("Data fields") + ordering = ('question', 'id') unique_together = (('event', 'identifier'),) def __str__(self): @@ -1964,6 +1966,105 @@ class QuestionOption(models.Model): ordering = ('position', 'id') +class Questionnaire(LoggedModel): + TYPE_ORDER_SALE = "OS" + TYPE_ORDER_POSITION_SALE = "PS" + TYPE_ORDER_POSITION_ATTENDEE_ONLY = "PA" + TYPE_ORDER_POSITION_CHECKIN = "PC" + TYPE_ORDER_POSITION_HIDDEN = "PH" + TYPE_CHOICES = ( + (TYPE_ORDER_SALE, _("Order-wide, before purchase")), + (TYPE_ORDER_POSITION_SALE, _("Per product, before purchase")), + (TYPE_ORDER_POSITION_ATTENDEE_ONLY, _("Per product, via attendee link")), + (TYPE_ORDER_POSITION_CHECKIN, _("Per product, at check-in")), + (TYPE_ORDER_POSITION_HIDDEN, _("Per product, hidden")), + ) + event = models.ForeignKey( + Event, + related_name="questionnaires", + on_delete=models.CASCADE + ) + internal_name = models.CharField( + verbose_name=_("Internal name"), + max_length=255, + ) + type = models.CharField( + max_length=5, + choices=TYPE_CHOICES, + verbose_name=_("Questionnaire type") + ) + items = models.ManyToManyField( + Item, + related_name='questionnaires', + verbose_name=_("Products"), + blank=True, + help_text=_('This questionnaire will be asked to buyers of the selected products') + ) + position = models.PositiveIntegerField( + default=0, + verbose_name=_("Position") + ) + all_sales_channels = models.BooleanField( + verbose_name=_("Sell on all sales channels the product is sold on"), + default=True, + ) + limit_sales_channels = models.ManyToManyField( + "SalesChannel", + verbose_name=_("Restrict to specific sales channels"), + help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is ' + 'selected here but not on product level, the variation will not be available.'), + blank=True, + ) + + +class QuestionnaireChild(LoggedModel): + SYSTEM_QUESTION_CHOICES = ( + ('attendee_name_parts', _('Attendee name')), + ('attendee_email', _('Attendee email')), + ('company', _('Company')), + ('street', _('Street')), + ('zipcode', _('ZIP code')), + ('city', _('City')), + ('country', _('Country')), + ) + questionnaire = models.ForeignKey( + Questionnaire, + related_name="children", + on_delete=models.CASCADE + ) + position = models.PositiveIntegerField( + default=0, + verbose_name=_("Position") + ) + user_question = models.ForeignKey( + Question, + related_name="references", + on_delete=models.CASCADE, + null=True, blank=True, + ) + system_question = models.CharField( + max_length=25, + choices=SYSTEM_QUESTION_CHOICES, + null=True, blank=True, + ) + required = models.BooleanField( + default=False, + verbose_name=_("Required question") + ) + label = I18nTextField( + verbose_name=_("Question") + ) + help_text = I18nTextField( + verbose_name=_("Help text"), + help_text=_("If the question needs to be explained or clarified, do it here!"), + null=True, blank=True, + ) + dependency_question = models.ForeignKey( + 'QuestionnaireChild', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions' + ) + dependency_values = MultiStringField(default=[]) + + class Quota(LoggedModel): """ A quota is a "pool of tickets". It is there to limit the number of items diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 8b80fa4a8f..3036c68e0f 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1447,6 +1447,12 @@ class QuestionAnswer(models.Model): else: return self.answer + def to_dependency_values(self): + if self.question.type in (Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE): + return [o.identifier for o in self.options.all()] + elif self.question.type in (Question.TYPE_BOOLEAN, Question.TYPE_COUNTRYCODE): + return self.answer + def save(self, *args, **kwargs): if self.orderposition and self.cartposition: raise ValueError('QuestionAnswer cannot be linked to an order and a cart position at the same time.') @@ -1591,53 +1597,80 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model): def cache_answers(self, all=True): """ - Creates two properties on the object. - (1) answ: a dictionary of question.id → answer string - (2) questions: a list of Question objects, extended by an 'answer' property + Creates a new property on the object: + questions: a list of Question objects, extended by an 'answer' property """ - self.answ = {} - for a in getattr(self, 'answerlist', self.answers.all()): # use prefetch_related cache from get_cart - self.answ[a.question_id] = a - # 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 if not all: - if hasattr(self.item, 'questions_to_ask'): - questions = list(copy.copy(q) for q in self.item.questions_to_ask) + if hasattr(self.item, 'relevant_questionnaires'): + children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist) else: - questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False, - hidden=False)) + children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type='PS') for qc in qq.children.all()) else: - questions = list(copy.copy(q) for q in self.item.questions.all()) + children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type__startswith='P') for qc in qq.children.all()) - question_cache = { - q.pk: q for q in questions + qc_cache = { + q.pk: q for q in children } - def question_is_visible(parentid, qvals): - if parentid not in question_cache: + def qc_is_visible(parentid, qvals): + if parentid not in qc_cache: return False - parentq = question_cache[parentid] - if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values): + parentqc = qc_cache[parentid] + if parentqc.dependency_question_id and not qc_is_visible(parentqc.dependency_question_id, parentqc.dependency_values): return False - if parentid not in self.answ: - return False - return ( - ('True' in qvals and self.answ[parentid].answer == 'True') - or ('False' in qvals and self.answ[parentid].answer == 'False') - or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals)) - ) + answer_values = self.get_dependency_answer_values(parentqc) + return any(qval in answer_values for qval in qvals) self.questions = [] - for q in questions: - if q.id in self.answ: - q.answer = self.answ[q.id] - q.answer.question = q # cache object + for qc in children: + if qc.user_question_id and qc.user_question_id in self.answer_cache: + qc.answer = self.answer_cache[qc.user_question_id] + #qc.answer.question = qc # cache object + elif qc.system_question: + qc.answer = self.get_system_answer(qc.system_question) + #qc.answer.question = qc # cache object else: - q.answer = "" - if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values): - self.questions.append(q) + qc.answer = "" + if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values): + self.questions.append(qc) + + @cached_property + def answer_cache(self): + return { + aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all()) + } + + def get_dependency_answer_values(self, qc): + if qc.user_question_id: + if qc.user_question_id not in self.answer_cache: + return None + answer = self.answer_cache[qc.user_question_id] + return answer.to_dependency_values() + elif qc.system_question: + return [self.get_system_answer(qc.system_question)] + else: + raise ValueError('Questionnaire child without question has no answer') + + def get_system_answer(self, system_question_name): + if system_question_name == 'attendee_name_parts': + return self.attendee_name_parts + elif system_question_name == 'attendee_email': + return self.attendee_email + elif system_question_name == 'street': + return self.street + elif system_question_name == 'zipcode': + return self.zipcode + elif system_question_name == 'city': + return self.city + elif system_question_name == 'state': + return self.state + elif system_question_name == 'country': + return self.country + else: + raise ValueError('Unknown system question name') @property def net_price(self): diff --git a/src/pretix/base/payment.py b/src/pretix/base/payment.py index c8047bbd5c..7e33b99935 100644 --- a/src/pretix/base/payment.py +++ b/src/pretix/base/payment.py @@ -72,7 +72,7 @@ from pretix.helpers.countries import CachedCountries from pretix.helpers.format import format_map from pretix.helpers.money import DecimalTextInput from pretix.multidomain.urlreverse import eventreverse_absolute -from pretix.presale.views import get_cart +from pretix.presale.views import get_cart_positions from pretix.presale.views.cart import cart_session, get_or_create_cart_id logger = logging.getLogger(__name__) @@ -1148,7 +1148,7 @@ class FreeOrderProvider(BasePaymentProvider): def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool: from .services.cart import get_fees - cart = get_cart(request) + cart = get_cart_positions(request) try: fees = get_fees(event=request.event, request=request, @@ -1416,7 +1416,7 @@ class GiftCardPayment(BasePaymentProvider): for p in cs.get('payments', []) if p.get('info_data', {}).get('gift_card') ] - positions = get_cart(request) + positions = get_cart_positions(request) testmode = self.event.testmode else: used_cards = [] diff --git a/src/pretix/base/views/mixins.py b/src/pretix/base/views/mixins.py index f85228d8a9..9dec514553 100644 --- a/src/pretix/base/views/mixins.py +++ b/src/pretix/base/views/mixins.py @@ -27,7 +27,7 @@ from decimal import Decimal from django import forms from django.core.files.uploadedfile import UploadedFile from django.db import IntegrityError -from django.db.models import Prefetch, QuerySet +from django.db.models import Prefetch, Q, QuerySet from django.utils.functional import cached_property from django.utils.timezone import make_aware @@ -36,7 +36,7 @@ from pretix.base.forms.questions import ( ) from pretix.base.models import ( CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer, - QuestionOption, + QuestionOption, QuestionnaireChild, ) from pretix.base.models.customers import AttendeeProfile from pretix.presale.signals import contact_form_fields_overrides @@ -56,6 +56,20 @@ class BaseQuestionsViewMixin: def question_form_kwargs(self, cr): return {} + @cached_property + def order_questions_form(self): + kwargs = {} # self.question_form_kwargs(cr) + form = self.form_class(event=self.request.event, + prefix='order', + request=self.request, + cartpos=None, + orderpos=None, + 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), + **kwargs) + return form + @cached_property def forms(self): """ @@ -307,27 +321,37 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin): @cached_property def positions(self): - qqs = self.request.event.questions.all() + qqs = self.request.event.questionnaires.all() if self.only_user_visible: - qqs = qqs.filter(ask_during_checkin=False, hidden=False) + qqs = qqs.filter(type='PS') + else: + qqs = qqs.filter(type__startswith='P') + qqs = qqs.filter( + Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=self.order.sales_channel.identifier) + ) return list(self.order.positions.select_related( 'item', 'variation' ).prefetch_related( Prefetch('answers', QuestionAnswer.objects.prefetch_related('options'), to_attr='answerlist'), - Prefetch('item__questions', + Prefetch('item__questionnaires', qqs.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' - ))) - ).select_related('dependency_question'), - to_attr='questions_to_ask') + Prefetch('children', QuestionnaireChild.objects.prefetch_related( + Prefetch('user_question', Question.objects.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='childlist') + ), + to_attr='relevant_questionnaires') )) @cached_property diff --git a/src/pretix/base/views/redirect.py b/src/pretix/base/views/redirect.py index 5ecfabee17..3566856f2e 100644 --- a/src/pretix/base/views/redirect.py +++ b/src/pretix/base/views/redirect.py @@ -26,6 +26,7 @@ from django.core import signing from django.http import HttpResponseBadRequest, HttpResponseRedirect from django.shortcuts import render from django.urls import reverse +from django.utils.html import format_html logger = logging.getLogger(__name__) @@ -60,6 +61,7 @@ def redir_view(request): u = urllib.parse.urlparse(url) return render(request, 'pretixbase/redirect.html', { 'hostname': u.hostname, + 'bold_hostname': format_html("{}", u.hostname), 'url': url, }) diff --git a/src/pretix/control/logdisplay.py b/src/pretix/control/logdisplay.py index 60d44f50a2..68ae757afc 100644 --- a/src/pretix/control/logdisplay.py +++ b/src/pretix/control/logdisplay.py @@ -861,6 +861,9 @@ class OrganizerPluginStateLogEntryType(LogEntryType): 'pretix.event.question.option.added': _('An answer option has been added to the question.'), 'pretix.event.question.option.deleted': _('An answer option has been removed from the question.'), 'pretix.event.question.option.changed': _('An answer option has been changed.'), + 'pretix.event.questionnaire.added': _('A questionnaire has been created.'), + 'pretix.event.questionnaire.deleted': _('A questionnaire has been deleted.'), + 'pretix.event.questionnaire.changed': _('A questionnaire has been changed.'), 'pretix.event.permissions.added': _('A user has been added to the event team.'), 'pretix.event.permissions.invited': _('A user has been invited to the event team.'), 'pretix.event.permissions.changed': _('A user\'s permissions have been changed.'), diff --git a/src/pretix/control/navigation.py b/src/pretix/control/navigation.py index 61e3411344..c1959680e3 100644 --- a/src/pretix/control/navigation.py +++ b/src/pretix/control/navigation.py @@ -182,12 +182,12 @@ def get_event_navigation(request: HttpRequest): 'active': 'event.items.categories' in url.url_name, }, { - 'label': _('Questions'), - 'url': reverse('control:event.items.questions', kwargs={ + 'label': _('Questionnaires'), + 'url': reverse('control:event.items.questionnaires', kwargs={ 'event': request.event.slug, 'organizer': request.event.organizer.slug, }), - 'active': 'event.items.questions' in url.url_name, + 'active': 'event.items.questionnaires' in url.url_name or 'event.items.questions' in url.url_name, }, { 'label': _('Discounts'), diff --git a/src/pretix/control/templates/pretixcontrol/items/question_edit.html b/src/pretix/control/templates/pretixcontrol/items/question_edit.html index e42b084cf7..3f8e5b2458 100644 --- a/src/pretix/control/templates/pretixcontrol/items/question_edit.html +++ b/src/pretix/control/templates/pretixcontrol/items/question_edit.html @@ -12,142 +12,113 @@ {% endblock %} {% block inside %} {% if question %} -

{% blocktrans with name=question.question %}Question: {{ name }}{% endblocktrans %}

+

{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}

{% else %} -

{% trans "Question" %}

+

{% trans "Data field" %}

{% endif %}
{% csrf_token %} {% bootstrap_form_errors form %} -
-
- {% trans "General" %} - {% bootstrap_field form.question layout="control" %} - {% bootstrap_field form.type layout="control" %} - {% bootstrap_field form.items layout="control" %} - {% bootstrap_field form.required layout="control" %} -
- {% blocktrans trimmed %} - If you mark a Yes/No question as required, it means that the user has to select Yes and No is not - accepted. If you want to allow both options, do not make this field required. - {% endblocktrans %} -
-
- {% bootstrap_field form.valid_number_min layout="control" %} - {% bootstrap_field form.valid_number_max layout="control" %} -
-
- {% bootstrap_field form.valid_date_min layout="control" %} - {% bootstrap_field form.valid_date_max layout="control" %} -
-
- {% bootstrap_field form.valid_datetime_min layout="control" %} - {% bootstrap_field form.valid_datetime_max layout="control" %} -
-
- {% bootstrap_field form.valid_string_length_max layout="control" %} -
-
- {% bootstrap_field form.valid_file_portrait layout="control" %} -
-
-

{% trans "Answer options" %}

- -
- {{ formset.management_form }} - {% bootstrap_formset_errors formset %} -
- {% for form in formset %} -
-
- {{ form.id }} - {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} - {% bootstrap_field form.ORDER form_group_class="" layout="inline" %} -
-
-
- - {% blocktrans trimmed with id=form.instance.identifier %} - Answer option {{ id }} - {% endblocktrans %} - - {% bootstrap_form_errors form %} - {% bootstrap_field form.answer layout='inline' form_group_class="" %} -
-
-  
- - - -
-
-
- {% endfor %} -
- -

- -

-
-
-
-
- {% trans "Advanced" %} - {% bootstrap_field form.help_text layout="control" %} - {% bootstrap_field form.identifier layout="control" %} - {% bootstrap_field form.ask_during_checkin layout="control" %} - {% bootstrap_field form.show_during_checkin layout="control" %} - {% bootstrap_field form.hidden layout="control" %} - {% bootstrap_field form.print_on_invoice layout="control" %} - -
- -
- {% bootstrap_field form.dependency_question layout="inline" form_group_class="inner" %} -
-
- - {% bootstrap_field form.dependency_values layout="inline" form_group_class="inner" %} -
-
-
+ {% bootstrap_field form.question layout="control" %} + {% bootstrap_field form.type layout="control" %} +
+ {% bootstrap_field form.valid_number_min layout="control" %} + {% bootstrap_field form.valid_number_max layout="control" %}
+
+ {% bootstrap_field form.valid_date_min layout="control" %} + {% bootstrap_field form.valid_date_max layout="control" %} +
+
+ {% bootstrap_field form.valid_datetime_min layout="control" %} + {% bootstrap_field form.valid_datetime_max layout="control" %} +
+
+ {% bootstrap_field form.valid_string_length_max layout="control" %} +
+
+ {% bootstrap_field form.valid_file_portrait layout="control" %} +
+
+

{% trans "Answer options" %}

+ +
+ {{ formset.management_form }} + {% bootstrap_formset_errors formset %} +
+ {% for form in formset %} +
+
+ {{ form.id }} + {% bootstrap_field form.DELETE form_group_class="" layout="inline" %} + {% bootstrap_field form.ORDER form_group_class="" layout="inline" %} +
+
+
+ + {% blocktrans trimmed with id=form.instance.identifier %} + Answer option {{ id }} + {% endblocktrans %} + + {% bootstrap_form_errors form %} + {% bootstrap_field form.answer layout='inline' form_group_class="" %} +
+
+  
+ + + +
+
+
+ {% endfor %} +
+ +

+ +

+
+
+ {% bootstrap_field form.identifier layout="control" %} + {% bootstrap_field form.ask_during_checkin layout="control" %} + {% bootstrap_field form.show_during_checkin layout="control" %} + {% bootstrap_field form.hidden layout="control" %} + {% bootstrap_field form.print_on_invoice layout="control" %} + +