From 6ed34abb991627acf93a2e223ca9b128372f5136 Mon Sep 17 00:00:00 2001 From: Mira Weller Date: Wed, 30 Sep 2026 20:29:07 +0200 Subject: [PATCH] pretixSCAN compat --- src/pretix/api/serializers/item.py | 8 +++++ src/pretix/api/serializers/order.py | 18 +++++------ src/pretix/api/views/checkin.py | 28 +++++++++-------- src/pretix/base/models/items.py | 7 +++-- src/pretix/base/models/orders.py | 45 +++++++++++++++------------- src/pretix/base/services/cart.py | 1 + src/pretix/base/services/checkin.py | 32 ++++++++++---------- src/pretix/control/views/orders.py | 2 +- src/pretix/presale/checkoutflow.py | 2 +- src/pretix/presale/views/__init__.py | 4 +-- 10 files changed, 84 insertions(+), 63 deletions(-) diff --git a/src/pretix/api/serializers/item.py b/src/pretix/api/serializers/item.py index 4b64f62463..793cf1fc1c 100644 --- a/src/pretix/api/serializers/item.py +++ b/src/pretix/api/serializers/item.py @@ -592,6 +592,14 @@ class CompatQuestionSerializer(I18nAwareModelSerializer): super().__init__(*args, **kwargs) self.fields['items'].child_relation.queryset = self.context['event'].items.all() if 'event' in self.context else Item.objects.none() + def get_attribute(self, instance): + if isinstance(instance, QuestionAnswer): + position = instance.cartposition if instance.cartposition_id else instance.orderposition + print('searching ',repr(instance), repr(position), repr(instance.question), instance.pk, position.item_id, self.context) + qcs = list(QuestionnaireChild.objects.filter(user_datafield_id=instance.question_id, questionnaire__items__in=[position.item_id])) + return next((qc for qc in qcs if qc.questionnaire.type == self.context.get('preferred_questionnaire_type', Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)), qcs[0]) + + return super().get_attribute(instance) def cook_dependency_question_id(self, qc): if qc.dependency_question: diff --git a/src/pretix/api/serializers/order.py b/src/pretix/api/serializers/order.py index 77fc9a364f..9384fe58f5 100644 --- a/src/pretix/api/serializers/order.py +++ b/src/pretix/api/serializers/order.py @@ -48,8 +48,7 @@ from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.forms import form_field_to_serializer_field from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.item import ( - InlineItemVariationSerializer, ItemSerializer, DatafieldSerializer, cook_id, cook_id_from_parts, - system_datafield_numbers, uncook_id, + CompatQuestionSerializer, InlineItemVariationSerializer, ItemSerializer, ) from pretix.api.signals import order_api_details, orderposition_api_details from pretix.base.decimal import round_decimal @@ -59,7 +58,7 @@ from pretix.base.media import MEDIA_TYPES from pretix.base.models import ( CachedFile, Checkin, Customer, Device, GiftCard, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, OrderPosition, Question, - QuestionAnswer, QuestionnaireChild, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, + QuestionAnswer, Questionnaire, QuestionnaireChild, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher, ) from pretix.base.models.orders import ( @@ -247,7 +246,7 @@ class MixedAnswerListSerializer(ListSerializer): if qc.user_datafield_id: if answer := answers_dict.get(qc.user_datafield_id): answer = dict(answer) - answer["question"] = cook_id(qc) + answer["question"] = qc.cooked_id answer["questionnaire_id"] = qc.questionnaire_id answer["user_datafield_id"] = qc.user_datafield_id answers.append(answer) @@ -270,7 +269,7 @@ class MixedAnswerListSerializer(ListSerializer): except ValueError: pass answers.append({ - "question": cook_id_from_parts(qc.questionnaire_id, None, f'{qc.system_datafield}:{k}'), + "question": QuestionnaireChild.cook_id_from_parts(qc.questionnaire_id, None, f'{qc.system_datafield}:{k}'), "answer": name_parts[k], "question_identifier": f'@{qc.system_datafield}:{k}', "options": options, @@ -279,7 +278,7 @@ class MixedAnswerListSerializer(ListSerializer): "system_datafield": f'{qc.system_datafield}:{k}', }) answers.append({ - "question": cook_id(qc), + "question": qc.cooked_id, "answer": sys_answer, "question_identifier": f'@{qc.system_datafield}', "options": [], @@ -296,9 +295,9 @@ class MixedAnswerListSerializer(ListSerializer): settings = self.context['event'].settings for answer in data["answers"]: try: - qid, dfid, sys_df = uncook_id(answer["question"]) + qid, dfid, sys_df = QuestionnaireChild.uncook_id(answer["question"]) except ValueError: - if type(answer["question"]) is str and answer["question"].lstrip("@") in system_datafield_numbers: + if type(answer["question"]) is str and answer["question"].lstrip("@") in QuestionnaireChild.SYSTEM_DATAFIELD_NUMBERS: qid, dfid, sys_df = None, None, answer["question"].lstrip("@") else: raise ValidationError('Invalid question ID') @@ -784,6 +783,7 @@ class AttendeeNamePartsField(serializers.Field): class CheckinListOrderPositionSerializer(OrderPositionSerializer): require_attention = RequireAttentionField(source='*') + answers = AnswerSerializer(many=True) attendee_name = AttendeeNameField(source='*') attendee_name_parts = AttendeeNamePartsField(source='*') order__status = serializers.SlugRelatedField(read_only=True, slug_field='status', source='order') @@ -813,7 +813,7 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer): self.fields['variation'] = InlineItemVariationSerializer(read_only=True, context=self.context) if 'answers.question' in self.context['expand']: - self.fields['answers'].child.fields['question'] = DatafieldSerializer(read_only=True) # TODO(questionnaires) + self.fields['answers'].child.fields['question'] = CompatQuestionSerializer(read_only=True) # TODO(questionnaires) if 'addons' in self.context['expand']: # Experimental feature, undocumented on purpose for now in case we need to remove it again diff --git a/src/pretix/api/views/checkin.py b/src/pretix/api/views/checkin.py index 84039c5a85..c8b315529e 100644 --- a/src/pretix/api/views/checkin.py +++ b/src/pretix/api/views/checkin.py @@ -55,7 +55,7 @@ from pretix.api.serializers.checkin import ( CheckinListSerializer, CheckinRPCAnnulInputSerializer, CheckinRPCRedeemInputSerializer, MiniCheckinListSerializer, ) -from pretix.api.serializers.item import DatafieldSerializer +from pretix.api.serializers.item import CompatQuestionSerializer from pretix.api.serializers.order import ( CheckinListOrderPositionSerializer, CheckinSerializer, FailedCheckinSerializer, @@ -65,7 +65,7 @@ from pretix.api.views.order import OrderPositionFilter from pretix.base.i18n import language from pretix.base.models import ( CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition, - Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken, + Question, Questionnaire, QuestionnaireChild, ReusableMedium, RevokedTicketSecret, TeamAPIToken, ) from pretix.base.models.orders import PrintLog from pretix.base.permissions import AnyPermissionOf @@ -489,6 +489,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, 'pdf_data': pdf_data and ( user if user and user.is_authenticated else auth ).has_event_permission(request.organizer, event, 'event.orders:read', request), + 'preferred_questionnaire_type': Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN, } common_checkin_args = dict( @@ -797,16 +798,19 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, # 5. Pre-validate all incoming answers, handle file upload given_answers = {} if answers_data: - for q in op.item.questions.filter(ask_during_checkin=True): - if str(q.pk) in answers_data: + for qc in QuestionnaireChild.objects.filter( + questionnaire__items__in=[op.item], questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN + ): + if qc.user_datafield_id and (str(qc.user_datafield_id) in answers_data or str(qc.cooked_id) in answers_data): try: - if q.type == Question.TYPE_FILE: - if answers_data[str(q.pk)]: - given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth) + ans = answers_data.get(str(qc.user_datafield_id), answers_data[str(qc.cooked_id)]) + if qc.user_datafield.type == Question.TYPE_FILE: + if ans: + given_answers[qc.user_datafield] = _handle_file_upload(ans, user, auth) else: - given_answers[q] = None + given_answers[qc.user_datafield] = None else: - given_answers[q] = q.clean_answer(answers_data[str(q.pk)]) + given_answers[qc.user_datafield] = qc.user_datafield.clean_answer(ans) except (ValidationError, BaseValidationError): pass @@ -867,14 +871,13 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, else: perform_checkin(**checkin_args) except RequiredQuestionsError as e: + qs = CompatQuestionSerializer(context={'event': op.order.event}) return Response({ 'status': 'incomplete', 'require_attention': op.require_checkin_attention, 'checkin_texts': op.checkin_texts, 'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, op.order.event)).data, - 'questions': [ - DatafieldSerializer(q).data for q in e.questions # TODO(questionnaires) - ], + 'questions': CompatQuestionSerializer.mangle_results(op.order.event, (qs.to_representation(q) for q in e.questions)), # TODO(questionnaires) 'list': MiniCheckinListSerializer(list_by_event[op.order.event_id]).data, }, status=400) except RequiredMediaExchangeError as e: @@ -973,6 +976,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): ctx['event'] = self.request.event ctx['expand'] = self.request.query_params.getlist('expand') ctx['pdf_data'] = self.request.query_params.get('pdf_data', 'false').lower() == 'true' + ctx['preferred_questionnaire_type'] = Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN return ctx def get_filterset_kwargs(self): diff --git a/src/pretix/base/models/items.py b/src/pretix/base/models/items.py index 7879f0064f..a19f4339da 100644 --- a/src/pretix/base/models/items.py +++ b/src/pretix/base/models/items.py @@ -1807,9 +1807,10 @@ class Question(LoggedModel): return self.sortkey < other.sortkey def clean_answer(self, answer): - if self.required: - if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)): - raise ValidationError(_('An answer to this question is required to proceed.')) + # TODO(questionnaires) - we don't have Question.required any more - do we actually need this check? if yes, pass in required from caller + # if self.required: + # if not answer or (self.type == Question.TYPE_BOOLEAN and answer not in ("true", "True", True)): + # raise ValidationError(_('An answer to this question is required to proceed.')) if not answer: if self.type == Question.TYPE_BOOLEAN: return False diff --git a/src/pretix/base/models/orders.py b/src/pretix/base/models/orders.py index 1855c15d67..4c121a6d35 100644 --- a/src/pretix/base/models/orders.py +++ b/src/pretix/base/models/orders.py @@ -95,7 +95,7 @@ from ._transactions import ( ) from .base import LockModel, LoggedModel from .event import Event, SubEvent -from .items import Item, ItemVariation, Question, QuestionOption, Quota +from .items import Item, ItemVariation, Question, QuestionOption, QuestionnaireChild, Quota logger = logging.getLogger(__name__) @@ -1597,7 +1597,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model): def meta_info_data(self, d): self.meta_info = json.dumps(d) - def cache_answers(self, all=True): + def cache_answers(self, questionnaire_type, sales_channel): """ Creates a new property on the object: questions: a list of Question objects, extended by an 'answer' property @@ -1605,13 +1605,16 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model): # 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, 'relevant_questionnaires'): - children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist) - else: - children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type='PS') for qc in qq.children.all()) + if hasattr(self.item, 'relevant_questionnaires'): + children = list(copy.copy(qc) for qq in self.item.relevant_questionnaires for qc in qq.childlist) else: - children = list(copy.copy(qc) for qq in self.item.questionnaires.filter(type__startswith='P') for qc in qq.children.all()) + children = QuestionnaireChild.objects.filter(questionnaire__event_id=self.item.event_id, questionnaire__items__in=[self.item.id]) + if questionnaire_type is not None: + children = children.filter(questionnaire__type=questionnaire_type) + if sales_channel is not None: + children = children.filter(Q(questionnaire__all_sales_channels=True) | Q(questionnaire__limit_sales_channel=sales_channel)) + + children = list(children) qc_cache = { q.pk: q for q in children @@ -1624,28 +1627,30 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model): if parentqc.dependency_question_id and not qc_is_visible(parentqc.dependency_question_id, parentqc.dependency_values): return False answer_values = self.get_dependency_answer_values(parentqc) - return any(qval in answer_values for qval in qvals) + return answer_values and any(qval in answer_values for qval in qvals) self.questions = [] for qc in children: - if qc.user_datafield_id: - if qc.user_datafield_id in self.answer_cache: - qc.answer = self.answer_cache[qc.user_datafield_id] - else: - qc.answer = "" - #qc.answer.question = qc # cache object - elif qc.system_datafield: - qc.answer = self.get_system_answer(qc.system_datafield) - #qc.answer.question = qc # cache object - else: + try: + qc.answer = self.answer_cache[qc.user_datafield_id or qc.system_datafield] + except KeyError: continue if not qc.dependency_question_id or qc_is_visible(qc.dependency_question_id, qc.dependency_values): self.questions.append(qc) + return self.questions @cached_property def answer_cache(self): return { - aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all()) + "attendee_name_parts": self.attendee_name, + "attendee_email": self.attendee_email, + "company": self.company, + "street": self.street, + "zipcode": self.zipcode, + "city": self.city, + "state": self.state, + "country": str(self.country), + **{aw.question_id: aw for aw in getattr(self, 'answerlist', self.answers.all())}, } def get_dependency_answer_values(self, qc): diff --git a/src/pretix/base/services/cart.py b/src/pretix/base/services/cart.py index 9e1c2e45fc..2fd31ca06c 100644 --- a/src/pretix/base/services/cart.py +++ b/src/pretix/base/services/cart.py @@ -1410,6 +1410,7 @@ class CartManager: for k, v in self._widget_data.items(): if not k.startswith('question-'): continue + # TODO(questionnaires) - switch to QuestionnaireChild q = cp.item.questions.filter(ask_during_checkin=False, identifier__iexact=k[9:]).first() if q: try: diff --git a/src/pretix/base/services/checkin.py b/src/pretix/base/services/checkin.py index 796c358edb..376d70070f 100644 --- a/src/pretix/base/services/checkin.py +++ b/src/pretix/base/services/checkin.py @@ -55,7 +55,7 @@ from django_scopes import scope, scopes_disabled from pretix.base.models import ( Checkin, CheckinList, Device, Event, Gate, Item, ItemVariation, Order, - OrderPosition, QuestionOption, + OrderPosition, QuestionOption, Questionnaire, QuestionnaireChild, ) from pretix.base.signals import checkin_created, periodic_task from pretix.helpers import OF_SELF @@ -897,14 +897,14 @@ def _save_answers(op, answers, given_answers): written = False for q, a in given_answers.items(): if not a: - if q in answers: + if q.id in answers: written = True - answers[q].delete() + answers[q.id].delete() else: continue if isinstance(a, QuestionOption): - if q in answers: - qa = answers[q] + if q.id in answers: + qa = answers[q.id] qa.answer = str(a.answer) qa.save() written = True @@ -913,8 +913,8 @@ def _save_answers(op, answers, given_answers): qa = _create_answer(question=q, answer=str(a.answer)) qa.options.add(a) elif isinstance(a, list): - if q in answers: - qa = answers[q] + if q.id in answers: + qa = answers[q.id] qa.answer = ", ".join([str(o) for o in a]) qa.save() written = True @@ -923,8 +923,8 @@ def _save_answers(op, answers, given_answers): qa = _create_answer(question=q, answer=", ".join([str(o) for o in a])) qa.options.add(*a) elif isinstance(a, File): - if q in answers: - qa = answers[q] + if q.id in answers: + qa = answers[q.id] else: qa = _create_answer(question=q, answer=str(a)) qa.file.save(os.path.basename(a.name), a, save=False) @@ -932,8 +932,8 @@ def _save_answers(op, answers, given_answers): qa.save() written = True else: - if q in answers: - qa = answers[q] + if q.id in answers: + qa = answers[q.id] qa.answer = str(a) qa.save() else: @@ -1033,17 +1033,19 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, # Do this outside of transaction so it is saved even if the checkin fails for some other reason checkin_questions = list( - clist.event.questions.filter(ask_during_checkin=True, items__in=[op.item_id]) + QuestionnaireChild.objects.filter( + questionnaire__event=clist.event, questionnaire__type=Questionnaire.QuestionnaireType.ORDER_POSITION_CHECKIN, + questionnaire__items__in=[op.item_id], + ).select_related('user_datafield', 'questionnaire') ) require_answers = [] if type != Checkin.TYPE_EXIT and checkin_questions: - answers = {a.question: a for a in op.answers.all()} for q in checkin_questions: - if q not in given_answers and q not in answers: + if q.user_datafield not in given_answers and q.user_datafield_id not in op.answer_cache: require_answers.append(q) if not simulate: - _save_answers(op, answers, given_answers) + _save_answers(op, op.answer_cache, given_answers) with conditional_atomic(not simulate): # Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 35f436ac7b..af57e8061d 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -626,7 +626,7 @@ class OrderDetail(OrderView): (p.item.ask_attendee_data and self.request.event.settings.attendee_emails_asked) or p.item.questionnaires.all() ) - p.cache_answers() + p.cache_answers(questionnaire_type=None, sales_channel=None) p.order = self.order positions.append(p) diff --git a/src/pretix/presale/checkoutflow.py b/src/pretix/presale/checkoutflow.py index 931c7dcb14..a160493f21 100644 --- a/src/pretix/presale/checkoutflow.py +++ b/src/pretix/presale/checkoutflow.py @@ -1103,7 +1103,7 @@ class QuestionsStep(CartQuestionsViewMixin, CartMixin, TemplateFlowStep): if parentqc.dependency_question_id and not question_is_visible(parentqc.dependency_question_id, parentqc.dependency_values): return False answer_values = cp.get_dependency_answer_values(parentqc) - return any(qval in answer_values for qval in qvals) + return answer_values and any(qval in answer_values for qval in qvals) def question_is_required(q): return ( diff --git a/src/pretix/presale/views/__init__.py b/src/pretix/presale/views/__init__.py index 74b51a4976..99c22a7bfe 100644 --- a/src/pretix/presale/views/__init__.py +++ b/src/pretix/presale/views/__init__.py @@ -52,7 +52,7 @@ from pretix.base.i18n import get_language_without_region, set_region from pretix.base.middleware import get_supported_language from pretix.base.models import ( CartPosition, Customer, InvoiceAddress, ItemAddOn, OrderFee, Question, - QuestionAnswer, QuestionOption, TaxRule, + QuestionAnswer, QuestionOption, Questionnaire, TaxRule, ) from pretix.base.models.items import QuestionnaireChild from pretix.base.models.orders import CheckoutSession @@ -251,7 +251,7 @@ class CartMixin: group.price_for_input_net = group.net_price_before_rounding + sum(a.net_price_before_rounding for a in has_addons[group.pk]) if answers: - group.cache_answers(all=False) + group.cache_answers(questionnaire_type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE, sales_channel=self.request.sales_channel) group.additional_answers = pos_additional_fields.get(group.pk) positions.append(group)