pretixSCAN compat

This commit is contained in:
Mira Weller
2026-09-30 21:03:28 +02:00
parent 38838c0309
commit 6ed34abb99
10 changed files with 84 additions and 63 deletions
+8
View File
@@ -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:
+9 -9
View File
@@ -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
+16 -12
View File
@@ -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):
+4 -3
View File
@@ -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
+25 -20
View File
@@ -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):
+1
View File
@@ -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:
+17 -15
View File
@@ -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
+1 -1
View File
@@ -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)
+1 -1
View File
@@ -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 (
+2 -2
View File
@@ -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)