CompatQuestionViewset / Serializer

This commit is contained in:
Mira Weller
2026-09-30 21:03:27 +02:00
parent d3434cc719
commit 67db5f6d7f
7 changed files with 256 additions and 34 deletions
+6
View File
@@ -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'),
+9 -9
View File
@@ -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',
]
+106 -4
View File
@@ -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
+1
View File
@@ -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)
+119 -6
View File
@@ -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):
+1 -1
View File
@@ -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')
+14 -14
View File
@@ -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.')