This commit is contained in:
Mira Weller
2026-07-24 17:37:46 +02:00
parent 5b34a25fb5
commit 3ea7ea31b6
12 changed files with 255 additions and 168 deletions
+65 -32
View File
@@ -1426,6 +1426,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.')
@@ -1570,53 +1576,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):
@@ -8,7 +8,7 @@
<div class="error-details">
<h1>{% trans "Redirect" %}</h1>
<h3>
{% blocktrans trimmed with host="<strong>"|add:hostname|add:"</strong>"|safe %}
{% blocktrans trimmed with host=bold_hostname %}
The link you clicked on wants to redirect you to a destination on the website {{ host }}.
{% endblocktrans %}
{% blocktrans trimmed %}
+2
View File
@@ -25,6 +25,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
def _is_samesite_referer(request):
@@ -52,6 +53,7 @@ def redir_view(request):
u = urllib.parse.urlparse(url)
return render(request, 'pretixbase/redirect.html', {
'hostname': u.hostname,
'bold_hostname': format_html("<strong>{}</strong>", u.hostname),
'url': url,
})
@@ -602,60 +602,9 @@
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name_all_components }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>
{% if line.attendee_email %}
{{ line.attendee_email }}
{% if not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
<dd>
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
<dd>
{% if line.street or line.zipcode or line.city or line.country %}
{{ line.street|default_if_none:""|linebreaksbr }}<br>
{{ line.zipcode|default_if_none:"" }} {{ line.city|default_if_none:"" }}<br>
{% if line.state %}{{ line.state_for_address }}<br>{% endif %}
{{ line.country.name|default_if_none:"" }}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
</dd>
{% endif %}
{% for q in line.questions %}
<dt>
{{ q.question }}
{{ q.label }}
{% if q.ask_during_checkin %}
<span class="fa fa-qrcode text-muted"
data-toggle="tooltip"
@@ -683,12 +632,27 @@
{% endif %}
{% elif q.type == "M" %}
{{ q.answer.to_string_i18n|rich_text_snippet }}
{% else %}
{% elif q.type %}
{{ q.answer.to_string_i18n|linebreaksbr }}
{% else %}{# TODO: proper separation of QuestionAnswer objects and system answers...... #}
{{ q.answer|linebreaksbr }}
{% endif %}
{% else %}
<em>{% trans "not answered" %}</em>
{% endif %}
{% if q.system_question == "attendee_email" and not line.addon_to %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}">
{% csrf_token %}
<a href="{% url "control:event.order.position.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code position=line.pk %}"
class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
</dd>
{% endfor %}
{% for q in line.additional_fields %}
+17 -40
View File
@@ -1060,26 +1060,18 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
return False
for cp in self._positions_for_questions:
answ = {
aw.question_id: aw for aw in cp.answerlist
}
question_cache = {
q.pk: q for q in cp.item.questions_to_ask
qc_cache = {
qc.pk: qc for qq in cp.item.relevant_questionnaires for qc in qq.childlist
}
def question_is_visible(parentid, qvals):
if parentid not in question_cache:
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 question_is_visible(parentqc.dependency_question_id, parentqc.dependency_values):
return False
if parentid not in answ:
return False
return (
('True' in qvals and answ[parentid].answer == 'True')
or ('False' in qvals and answ[parentid].answer == 'False')
or (any(qval in [o.identifier for o in answ[parentid].options.all()] for qval in qvals))
)
answer_values = cp.get_dependency_answer_values(parentqc)
return any(qval in answer_values for qval in qvals)
def question_is_required(q):
return (
@@ -1088,31 +1080,16 @@ class QuestionsStep(QuestionsViewMixin, CartMixin, TemplateFlowStep):
)
if not self.all_optional:
for q in cp.item.questions_to_ask:
if question_is_required(q) and q.id not in answ:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_names_required', as_type=bool) \
and not cp.attendee_name_parts:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_emails_required', as_type=bool) \
and cp.attendee_email is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_company_required', as_type=bool) \
and cp.company is None:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if cp.item.ask_attendee_data and self.request.event.settings.get('attendee_addresses_required', as_type=bool) \
and (cp.street is None and cp.city is None and cp.country is None):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
for qq in cp.item.relevant_questionnaires:
for qc in qq.childlist:
if qc.user_question_id and question_is_required(qc) and qc.user_question_id not in cp.answer_cache:
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
if qc.system_question and question_is_required(qc) and not cp.get_system_answer(qc.system_question):
if warn:
messages.warning(request, _('Please fill in answers to all required questions.'))
return False
responses = question_form_fields.send(sender=self.request.event, position=cp)
form_data = cp.meta_info_data.get('question_form_data', {})
@@ -204,7 +204,7 @@
{% endif %}
{% endif %}
{% for q in line.questions %}
<dt>{{ q.question }}</dt>
<dt>{{ q.label }}</dt>
<dd>
{% if q.answer %}
{% if q.answer.file %}
@@ -1,15 +1,32 @@
<script lang="ts">
import QuestionnaireElement from './QuestionnaireElement.vue';
import {create_questionnaire, get_datafields, get_items, get_questionnaires, update_questionnaire} from './api';
import * as api from './api';
import { Questionnaire } from './model';
import { i18n_any, QUESTION_TYPE } from './helper';
import { i18n_any, QUESTION_TYPE, sort, localeComp, numericComp, groupBy, _ } from './helper';
import {Ref, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
const items_list = await get_items();
const items_list = await api.getItems();
const categories_list = await api.getCategories();
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
categories['null'] = { position: -1, internal_name: _('Uncategorized') };
sort(items_list, numericComp(item => categories[item.category]?.position), numericComp(item => item.position));
console.log("items_list", items_list);
const grouped_items = [...groupBy(items_list, item => categories[item.category])];
console.log("grouped_items", grouped_items);
const questionnaires: Ref<(Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number })[]> = ref(await get_questionnaires());
const datafields = ref(await get_datafields());
const all_questionnaires: (Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number })[] = await api.getQuestionnaires();
const order_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('O')));
const position_questionnaires = ref(all_questionnaires.filter(q => q.type.startsWith('P')));
const datafields = ref(await api.getDatafields());
function saveQuestionnaire(questionnaire) {
if (questionnaire.id) {
api.updateQuestionnaire(questionnaire.id, questionnaire);
} else {
api.createQuestionnaire(questionnaire);
}
}
export default {
components: {
@@ -17,30 +34,38 @@ export default {
},
methods: {
i18n_any,
addQuestionnaire() {
questionnaires.value.push({
addPositionQuestionnaire() {
position_questionnaires.value.push({
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "PC",
items: [], internal_name: "Unnamed questionnaire", type: "PS",
_new_id: Date.now(),
});
},
addOrderQuestionnaire() {
order_questionnaires.value.push({
all_sales_channels: false, children: [], limit_sales_channels: [], position: 0,
items: [], internal_name: "Unnamed questionnaire", type: "OS",
_new_id: Date.now(),
});
},
saveData() {
console.log(JSON.parse(JSON.stringify(questionnaires.value)));
for (const questionnaire of questionnaires.value) {
if (questionnaire.id) {
update_questionnaire(questionnaire.id, questionnaire);
} else {
create_questionnaire(questionnaire);
}
for (const questionnaire of order_questionnaires.value) {
saveQuestionnaire(questionnaire);
}
for (const questionnaire of position_questionnaires.value) {
saveQuestionnaire(questionnaire);
}
},
},
data() {
return {
questionnaires,
order_questionnaires,
position_questionnaires,
datafields,
items: items_list,
selected_product: ref(""),
grouped_items,
categories,
}
}
}
@@ -61,30 +86,53 @@ export default {
.debuginfo { font-size: 70%; background: rgba(200, 200, 200, 0.5); }
.dependency-info { position: absolute; }
.dependency-info > span { }
.category-header { margin: 8px 0 -5px 0; font-weight: bold; color: #737373; }
</style>
<template>
<p class="filter-row">
Order questionnaires
</p>
<div class="question-editor">
<SlickList axis="y" v-model:list="order_questionnaires" useDragHandle appendTo="#orderQuestionnaireListParent" id="orderQuestionnaireListParent">
<SlickItem v-for="(questionnaire, index) in order_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:grouped_items="null"
:selected_product="null" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addOrderQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
<p class="filter-row">
Questionnaires for product:
<select v-model="selected_product">
<option value="">(all)</option>
<option v-for="item in items" :value="item.id">
{{ i18n_any(item.name) }}
</option>
<optgroup v-for="[category, items] in grouped_items" :label="category.internal_name || i18n_any(category.name)">
<option v-for="item in items" :value="item.id">
{{ item.internal_name || i18n_any(item.name) }}
</option>
</optgroup>
</select>
</p>
<div class="question-editor">
<SlickList axis="y" v-model:list="questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent">
<SlickItem v-for="(questionnaire, index) in questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<SlickList axis="y" v-model:list="position_questionnaires" useDragHandle appendTo="#questionnaireListParent" id="questionnaireListParent">
<SlickItem v-for="(questionnaire, index) in position_questionnaires" :key="questionnaire.id || questionnaire._new_id" :index="index">
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="datafields"
:items="items"
:grouped_items="grouped_items"
:selected_product="selected_product" />
</SlickItem>
</SlickList>
</div>
<p>
<button class="btn btn-default" @click="addQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="addPositionQuestionnaire()"><i class="fa fa-plus"></i> Neuen Fragebogen erstellen</button>
<button class="btn btn-default" @click="saveData()"><i class="fa fa-save"></i> Speichern</button>
</p>
</template>
@@ -1,8 +1,8 @@
<script setup lang="ts">
import { ref, useId, defineProps } from 'vue';
import {get_event_locales} from "./api";
import {getEventLocales} from "./api";
const locales = get_event_locales();
const locales = getEventLocales();
const props = defineProps(['value', 'id']);
@@ -4,7 +4,7 @@ import NativeDialog from './NativeDialog.vue';
import I18nTextField from './I18nTextField.vue';
import {useId, ref, computed} from 'vue'
import { DragHandle } from 'vue-slicksort';
import {get_datafield_edit_url} from "./api";
import {getDatafieldEditUrl} from "./api";
const id = useId();
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
@@ -99,7 +99,7 @@ const editor = ref();
<p class="form-control-static">
<template v-if="typeof question.question === 'number'">
{{ df.internal_name }}
<a :href="get_datafield_edit_url(df.id)" target="_blank">Manage data field details</a>
<a :href="getDatafieldEditUrl(df.id)" target="_blank">Manage data field details</a>
</template>
<template v-else>
{{ question.question }}
@@ -117,6 +117,18 @@ const editor = ref();
</select>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label label-empty">&nbsp;</label>
<div class="col-md-9">
<div class="checkbox">
<label>
<input type="checkbox" v-model="question.required">
{{ gettext('Required question') }}
</label>
</div>
</div>
</div>
<div class="form-group">
<label class="col-md-3 control-label">
{{ gettext('Only visible if...') }}
@@ -14,7 +14,7 @@ const newTextblockTitle = ref();
const newTextblockText = ref();
const id = useId();
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'items'])
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items'])
const gettext = (window as any).gettext
function toggleItem() {
@@ -106,15 +106,18 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<input type="text" class="form-control" v-model="questionnaire.internal_name"/>
</div>
</div>
<div class="form-group">
<div class="form-group" v-if="grouped_items">
<label class="col-md-3 control-label">
{{ gettext('Visible on products') }}
</label>
<div class="col-md-9">
<div class="checkbox" v-for="item in items">
<label :for="id + '_' + item.id">
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1"> {{ item.internal_name || i18n_any(item.name) }}
</label>
<div v-for="[category, items] in grouped_items">
<div class="category-header">{{ category.internal_name || i18n_any(category.name) }}</div>
<div class="checkbox" v-for="item in items">
<label :for="id + '_' + item.id">
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
</div>
</div>
@@ -30,34 +30,38 @@ async function api_json_request(resource, method, json_body) {
})).json();
}
export async function get_datafields() {
export async function getDatafields() {
return await api_get_all<Datafield>(`organizers/${organizer_slug}/events/${event_slug}/datafields/`);
}
export async function get_questionnaires() {
export async function getQuestionnaires() {
return await api_get_all<Questionnaire>(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`);
}
export async function update_questionnaire(id, data) {
export async function updateQuestionnaire(id, data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/${id}/`, 'PATCH', data);
}
export async function create_questionnaire(data) {
export async function createQuestionnaire(data) {
return await api_json_request(`organizers/${organizer_slug}/events/${event_slug}/questionnaires/`, 'POST', data);
}
export async function get_items() {
export async function getItems() {
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/items/`);
}
export async function getCategories() {
return await api_get_all<Item>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
}
function get_json_script_value(id) {
return JSON.parse(document.getElementById(id).innerText);
}
export function get_event_locales() {
export function getEventLocales() {
return get_json_script_value('event_locales');
}
export function get_datafield_edit_url(datafield_id) {
export function getDatafieldEditUrl(datafield_id) {
return get_json_script_value('datafield_edit_url').replace('/0/', `/${datafield_id}/`);
}
@@ -10,6 +10,50 @@ function freezeRec(o) {
return Object.freeze(Object.fromEntries(Object.entries(o).map(([k, v]) => [k, v && Object.getPrototypeOf(v) === Object.prototype ? freezeRec(v) : v])))
}
export function localeComp(fn) {
return function(a, b) {
return fn(a).localeCompare(fn(b));
}
}
export function numericComp(fn) {
return function(a, b) {
return fn(a) - fn(b);
}
}
export function pick(key) {
return function(obj) {
return obj[key];
}
}
export function sort(array, ...orderBy) {
array.sort(function(a, b) {
for(let comp of orderBy) {
const result = comp(a, b);
if (result !== 0) {
return result;
}
}
return 0;
});
}
export function *groupBy(array, key) {
let lastKey, lastArray;
for(const x of array){
const k = key(x);
if (lastKey !== k || !lastArray) {
if (lastArray) {
yield [lastKey, lastArray];
}
lastKey = k; lastArray = [x];
} else {
lastArray.push(x);
}
}
if (lastArray) {
yield [lastKey, lastArray];
}
}
export const QUESTION_TYPE = {
NUMBER: "N",
STRING: "S",
@@ -25,7 +69,7 @@ export const QUESTION_TYPE = {
PHONENUMBER: "TEL",
};
const _ = x => x;
export const _ = x => x;
export const QUESTION_TYPE_LABEL = {
NUMBER: _("Number"),