From 67db5f6d7fd4fa33d08d7e86f967d8708bd341e2 Mon Sep 17 00:00:00 2001 From: Mira Weller Date: Wed, 30 Sep 2026 01:22:15 +0200 Subject: [PATCH] CompatQuestionViewset / Serializer --- src/pretix/api/auth/devicesecurity.py | 6 ++ src/pretix/api/serializers/event.py | 18 ++-- src/pretix/api/serializers/item.py | 110 ++++++++++++++++++++++- src/pretix/api/urls.py | 1 + src/pretix/api/views/item.py | 125 ++++++++++++++++++++++++-- src/pretix/base/models/orders.py | 2 +- src/pretix/base/settings.py | 28 +++--- 7 files changed, 256 insertions(+), 34 deletions(-) diff --git a/src/pretix/api/auth/devicesecurity.py b/src/pretix/api/auth/devicesecurity.py index 8ef6d02181..4db26fd9bd 100644 --- a/src/pretix/api/auth/devicesecurity.py +++ b/src/pretix/api/auth/devicesecurity.py @@ -100,6 +100,8 @@ class PretixScanSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:itemcategory-list'), ('GET', 'api-v1:item-list'), ('GET', 'api-v1:question-list'), + ('GET', 'api-v1:questionnaire-list'), + ('GET', 'api-v1:questionnairechild-list'), ('GET', 'api-v1:badgelayout-list'), ('GET', 'api-v1:badgeitem-list'), ('GET', 'api-v1:checkinlist-list'), @@ -141,6 +143,8 @@ class PretixScanNoSyncNoSearchSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:itemcategory-list'), ('GET', 'api-v1:item-list'), ('GET', 'api-v1:question-list'), + ('GET', 'api-v1:questionnaire-list'), + ('GET', 'api-v1:questionnairechild-list'), ('GET', 'api-v1:badgelayout-list'), ('GET', 'api-v1:badgeitem-list'), ('GET', 'api-v1:checkinlist-list'), @@ -177,6 +181,8 @@ class PretixScanNoSyncSecurityProfile(AllowListSecurityProfile): ('GET', 'api-v1:itemcategory-list'), ('GET', 'api-v1:item-list'), ('GET', 'api-v1:question-list'), + ('GET', 'api-v1:questionnaire-list'), + ('GET', 'api-v1:questionnairechild-list'), ('GET', 'api-v1:badgelayout-list'), ('GET', 'api-v1:badgeitem-list'), ('GET', 'api-v1:checkinlist-list'), diff --git a/src/pretix/api/serializers/event.py b/src/pretix/api/serializers/event.py index 625eb8de1f..1b08756403 100644 --- a/src/pretix/api/serializers/event.py +++ b/src/pretix/api/serializers/event.py @@ -933,14 +933,14 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'show_quota_left', 'show_dates_on_frontpage', 'max_items_per_order', - 'attendee_names_asked', - 'attendee_names_required', - 'attendee_emails_asked', - 'attendee_emails_required', - 'attendee_addresses_asked', - 'attendee_addresses_required', - 'attendee_company_asked', - 'attendee_company_required', + #'attendee_names_asked', + #'attendee_names_required', + #'attendee_emails_asked', + #'attendee_emails_required', + #'attendee_addresses_asked', + #'attendee_addresses_required', + #'attendee_company_asked', + #'attendee_company_required', 'ticket_download', 'ticket_download_addons', 'ticket_download_nonadm', @@ -971,7 +971,7 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer): 'reusable_media_type_nfc_mf0aes', 'reusable_media_type_nfc_mf0aes_random_uid', 'reusable_media_usage_enforced', - 'system_question_order', # TODO(questionnaires) - remove or replace + # 'system_question_order', # TODO(questionnaires) - remove or replace 'tax_rule_payment', 'tax_rule_cancellation', ] diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 3a77ffa3af..8efbcd9943 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -33,6 +33,8 @@ # License for the specific language governing permissions and limitations under the License. import os.path from decimal import Decimal + +from i18nfield.rest_framework import I18nField from itertools import zip_longest import logging @@ -44,6 +46,8 @@ from django.db.models import QuerySet from django.utils.functional import cached_property, lazy from django.utils.translation import gettext_lazy as _ from rest_framework import serializers +from rest_framework.exceptions import ValidationError as DrfValidationError +from rest_framework.serializers import as_serializer_error from pretix.api.serializers import ( CompatDecimalField, SalesChannelMigrationMixin, @@ -546,6 +550,100 @@ class LegacyDependencyValueField(serializers.CharField): return [data] if data else [] + +system_datafield_numbers = [ + QuestionnaireChild.SystemQuestion.ATTENDEE_NAME_PARTS.value, + QuestionnaireChild.SystemQuestion.ATTENDEE_EMAIL.value, + QuestionnaireChild.SystemQuestion.COMPANY.value, + QuestionnaireChild.SystemQuestion.STREET.value, + QuestionnaireChild.SystemQuestion.ZIPCODE.value, + QuestionnaireChild.SystemQuestion.CITY.value, + QuestionnaireChild.SystemQuestion.STATE.value, + QuestionnaireChild.SystemQuestion.COUNTRY.value, +] + + +def cook_id(questionnaire_child): + df_id = questionnaire_child.user_datafield_id + 100 if questionnaire_child.user_datafield_id else system_datafield_numbers.index(questionnaire_child.system_datafield) + assert questionnaire_child.questionnaire_id < 1000000000 + return questionnaire_child.questionnaire_id * 1000000000 + df_id + + +def uncook_id(id): + id = int(id) + if id > 1000000000: + qid, dfid = divmod(id, 1000000000) + if dfid >= 100: + return qid, dfid - 100, None + else: + return qid, None, system_datafield_numbers[dfid] + else: + return None, id, None + + +class CompatQuestionSerializer(I18nAwareModelSerializer): + id = serializers.SerializerMethodField('cook_id', read_only=True) + question = I18nField(source='label', allow_null=True) + type = serializers.CharField(source='user_datafield.type', allow_null=True, default=Question.FieldType.TEXT) + #identifier = serializers.CharField(source='user_datafield.identifier', allow_null=True) + identifier = serializers.SerializerMethodField('get_identifier', read_only=True) + show_during_checkin = serializers.BooleanField(source='user_datafield.show_during_checkin', allow_null=True, default=False) + ask_during_checkin = serializers.SerializerMethodField('get_ask_during_checkin', read_only=True) + hidden = serializers.SerializerMethodField('get_hidden', read_only=True) + print_on_invoice = serializers.BooleanField(source='user_datafield.print_on_invoice', allow_null=True, default=False) + valid_number_min = serializers.IntegerField(source='user_datafield.valid_number_min', allow_null=True) + valid_number_max = serializers.IntegerField(source='user_datafield.valid_number_max', allow_null=True) + valid_date_min = serializers.DateField(source='user_datafield.valid_date_min', allow_null=True) + valid_date_max = serializers.DateField(source='user_datafield.valid_date_max', allow_null=True) + valid_datetime_min = serializers.DateTimeField(source='user_datafield.valid_datetime_min', allow_null=True) + valid_datetime_max = serializers.DateTimeField(source='user_datafield.valid_datetime_max', allow_null=True) + valid_string_length_max = serializers.IntegerField(source='user_datafield.valid_string_length_max', allow_null=True) + valid_string_length_min = serializers.IntegerField(source='user_datafield.valid_string_length_min', allow_null=True) + valid_file_portrait = serializers.BooleanField(source='user_datafield.valid_file_portrait', allow_null=True) + dependency_question = serializers.SerializerMethodField('cook_dependency_question_id', read_only=True) + dependency_value = serializers.SerializerMethodField('empty', read_only=True) + position = serializers.SerializerMethodField('get_position', read_only=True) + items = serializers.PrimaryKeyRelatedField(source='questionnaire.items', many=True, queryset=Item.objects.none(), allow_null=True, required=False) + + options = InlineQuestionOptionSerializer(source='user_datafield.options', many=True, required=False, default=[]) + + class Meta: + model = QuestionnaireChild + fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', + '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_string_length_min', 'valid_file_portrait') + + def __init__(self, *args, **kwargs): + self.fields['items'].child_relation.queryset = kwargs['context']['event'].items.all() + super().__init__(*args, **kwargs) + + def cook_id(self, qc): + return cook_id(qc) + + def cook_dependency_question_id(self, qc): + if qc.dependency_question: + return cook_id(qc.dependency_question) + else: + return None + + def get_ask_during_checkin(self, qc): + return qc.questionnaire.type == Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN + + def get_hidden(self, qc): + return qc.questionnaire.type == Questionnaire.QuestionnaireType.ORDER_POSITION_HIDDEN + + def get_identifier(self, qc): + return qc.user_datafield.identifier if qc.user_datafield else f'@{qc.system_datafield}' + + def empty(self, qc): + return None + + def get_position(self, qc): + return qc.questionnaire.position * 1000000000 + qc.position + + class DatafieldSerializer(I18nAwareModelSerializer): options = InlineQuestionOptionSerializer(many=True, required=False) identifier = serializers.CharField(allow_null=True) @@ -553,7 +651,7 @@ class DatafieldSerializer(I18nAwareModelSerializer): class Meta: model = Question - fields = ('id', 'question', 'type', 'options', + fields = ('id', 'type', 'options', 'show_during_checkin', 'identifier', 'hidden', 'print_on_invoice', 'valid_number_min', 'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max', @@ -649,10 +747,11 @@ class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer): question = QuestionRefField(allow_null=True, source='*', queryset=Question.objects.none()) dependency_question = QuestionRefField(allow_null=True, required=False, queryset=Question.objects.none()) rendered_help_text = RenderedMarkdownField(read_only=True, source='help_text') + cooked_id = serializers.SerializerMethodField('cook_id', read_only=True) class Meta: model = QuestionnaireChild - fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values', 'rendered_help_text') + fields = ('question', 'required', 'label', 'help_text', 'dependency_question', 'dependency_values', 'rendered_help_text', 'cooked_id') def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) @@ -666,6 +765,9 @@ class InlineQuestionnaireChildSerializer(I18nAwareModelSerializer): raise ValidationError('Question dependencies can only be set to boolean or choice questions.') return value + def cook_id(self, qc): + return cook_id(qc) + class QuestionnaireSerializer(I18nAwareModelSerializer): items = serializers.PrimaryKeyRelatedField(many=True, queryset=Item.objects.none(), allow_null=True, required=False) @@ -728,7 +830,7 @@ class QuestionnaireSerializer(I18nAwareModelSerializer): questionnaire = super().create(validated_data) self.set_children(questionnaire, children_data) try: - questionnaire.check_constraints() + Questionnaire.check_constraints(questionnaire.event) except ValidationError as e: raise DrfValidationError(as_serializer_error(e)) return questionnaire @@ -740,7 +842,7 @@ class QuestionnaireSerializer(I18nAwareModelSerializer): if children_data is not None: self.set_children(questionnaire, children_data) try: - questionnaire.check_constraints() + Questionnaire.check_constraints(questionnaire.event) except ValidationError as e: raise DrfValidationError(as_serializer_error(e)) return questionnaire diff --git a/src/pretix/api/urls.py b/src/pretix/api/urls.py index b7644295c6..36af31bf0d 100644 --- a/src/pretix/api/urls.py +++ b/src/pretix/api/urls.py @@ -82,6 +82,7 @@ event_router.register(r'items', item.ItemViewSet) event_router.register(r'categories', item.ItemCategoryViewSet) event_router.register(r'datafields', item.DatafieldViewSet) event_router.register(r'questionnaires', item.QuestionnaireViewSet) +event_router.register(r'questions', item.CompatQuestionViewSet) 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 3982b6fad3..3d70a2b786 100644 --- a/src/pretix/api/views/item.py +++ b/src/pretix/api/views/item.py @@ -46,17 +46,17 @@ from rest_framework.response import Response from pretix.api.pagination import TotalOrderingFilter from pretix.api.serializers.item import ( - ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer, + CompatQuestionSerializer, ItemAddOnSerializer, ItemBundleSerializer, ItemCategorySerializer, ItemProgramTimeSerializer, ItemSerializer, ItemVariationSerializer, QuestionnaireSerializer, QuestionOptionSerializer, DatafieldSerializer, - QuotaSerializer, + QuotaSerializer, uncook_id, ) 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.models.items import Questionnaire, QuestionnaireChild from pretix.base.services.quotas import QuotaAvailability from pretix.helpers.dicts import merge_dicts from pretix.helpers.i18n import i18ncomp @@ -460,7 +460,113 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet): with scopes_disabled(): - class QuestionFilter(FilterSet): + class CompatQuestionFilter(FilterSet): + identifier = django_filters.CharFilter(field_name='question__identifier') + ask_during_checkin = django_filters.BooleanFilter(method='ask_during_checkin_qs') + sales_channel = django_filters.CharFilter(method='sales_channel_qs') + + class Meta: + model = QuestionnaireChild + fields = ['required'] + + def ask_during_checkin_qs(self, qs, name, value): + return qs.filter( + questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN if value else Questionnaire.QuestionnaireType.ORDER_POSITION_SALE + ) + + def sales_channel_qs(self, qs, name, value): + return qs.filter( + Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channels__identifier=value) + ) + + +class CompatQuestionViewSet(ConditionalListView, viewsets.ModelViewSet): + serializer_class = CompatQuestionSerializer + queryset = QuestionnaireChild.objects.none() + filter_backends = (DjangoFilterBackend, TotalOrderingFilter) + filterset_class = CompatQuestionFilter + ordering_fields = ('questionnaire__position', 'position', 'id') + ordering = ('questionnaire__position', 'position', 'id') + permission = None + write_permission = 'event.items:write' + + def get_queryset(self): + return QuestionnaireChild.objects.filter( + questionnaire__event=self.request.event, + # legacy compat only for position-level questions + questionnaire__type__startswith='P', + ).prefetch_related('user_datafield', 'user_datafield__options', 'questionnaire').all() + + def get_object(self): + """ + Returns the object the view is displaying. + + You may want to override this if you need to provide non-standard + queryset lookups. Eg if objects are referenced using multiple + keyword arguments in the url conf. + """ + queryset = self.filter_queryset(self.get_queryset()) + + # Perform the lookup filtering. + lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field + + assert lookup_url_kwarg in self.kwargs, ( + 'Expected view %s to be called with a URL keyword argument ' + 'named "%s". Fix your URL conf, or set the `.lookup_field` ' + 'attribute on the view correctly.' % + (self.__class__.__name__, lookup_url_kwarg) + ) + + qid, dfid, sys_df = uncook_id(self.kwargs[lookup_url_kwarg]) + if qid and sys_df: + obj = get_object_or_404(queryset, questionnaire__pk=qid, system_datafield=sys_df) + elif qid and dfid: + obj = get_object_or_404(queryset, questionnaire__pk=qid, user_datafield__pk=dfid) + else: + obj = get_object_or_404(queryset, user_datafield__pk=dfid) + + # May raise a permission denied + self.check_object_permissions(self.request, obj) + + return obj + + @transaction.atomic() + def perform_create(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.question.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 + + @transaction.atomic() + def perform_update(self, serializer): + serializer.save(event=self.request.event) + serializer.instance.log_action( + 'pretix.event.question.changed', + user=self.request.user, + auth=self.request.auth, + data=self.request.data + ) + + @transaction.atomic() + def perform_destroy(self, instance): + instance.log_action( + 'pretix.event.question.deleted', + user=self.request.user, + auth=self.request.auth, + ) + super().perform_destroy(instance) + + +with scopes_disabled(): + class DatafieldFilter(FilterSet): class Meta: model = Question fields = ['identifier'] @@ -470,7 +576,7 @@ class DatafieldViewSet(ConditionalListView, viewsets.ModelViewSet): serializer_class = DatafieldSerializer queryset = Question.objects.none() filter_backends = (DjangoFilterBackend, TotalOrderingFilter) - filterset_class = QuestionFilter + filterset_class = DatafieldFilter ordering_fields = ('id') ordering = ('id') permission = None @@ -570,9 +676,16 @@ class QuestionOptionViewSet(viewsets.ModelViewSet): with scopes_disabled(): class QuestionnaireFilter(FilterSet): + sales_channel = django_filters.CharFilter(method='sales_channel_qs') + class Meta: model = Questionnaire - fields = ['type'] + fields = ['type', 'items', 'sales_channel'] + + def sales_channel_qs(self, qs, name, value): + return qs.filter( + Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=value) + ) class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet): diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 57232a0519..1855c15d67 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -1675,7 +1675,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model): elif system_datafield_name == 'state': return self.state elif system_datafield_name == 'country': - return self.country + return str(self.country) else: raise ValueError('Unknown system question name') diff --git a/src/pretix/base/settings.py b/src/pretix/base/settings.py index 9862f4e14b..a420764d1b 100644 --- a/src/pretix/base/settings.py +++ b/src/pretix/base/settings.py @@ -379,12 +379,12 @@ DEFAULTS = { ) }, - 'system_question_order': { # TODO(questionnaires) - remove this - 'default': {}, - 'type': dict, - 'serializer_class': serializers.DictField, - 'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True), - }, + # 'system_question_order': { # TODO(questionnaires) - remove this + # 'default': {}, + # 'type': dict, + # 'serializer_class': serializers.DictField, + # 'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True), + # }, 'order_email_asked_twice': { 'default': 'False', 'type': bool, @@ -4085,14 +4085,14 @@ def validate_event_settings(event, settings_dict): raise ValidationError({ 'locale': _('Your default locale must also be enabled for your event (see box above).') }) - if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'): - raise ValidationError({ - 'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.') - }) - if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'): - raise ValidationError({ - 'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.') - }) + # if settings_dict.get('attendee_names_required') and not settings_dict.get('attendee_names_asked'): + # raise ValidationError({ + # 'attendee_names_required': _('You cannot require specifying attendee names if you do not ask for them.') + # }) + # if settings_dict.get('attendee_emails_required') and not settings_dict.get('attendee_emails_asked'): + # raise ValidationError({ + # 'attendee_emails_required': _('You have to ask for attendee emails if you want to make them required.') + # }) if settings_dict.get('invoice_address_required') and not settings_dict.get('invoice_address_asked'): raise ValidationError({ 'invoice_address_required': _('You have to ask for invoice addresses if you want to make them required.')