This commit is contained in:
Mira Weller
2026-09-18 14:04:07 +02:00
parent 50c2c60a9b
commit 5fd6759474
17 changed files with 126 additions and 77 deletions
+4 -3
View File
@@ -553,9 +553,9 @@ class DatafieldSerializer(I18nAwareModelSerializer):
class Meta:
model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'show_during_checkin', 'identifier',
'hidden', 'print_on_invoice', 'help_text', 'valid_number_min',
fields = ('id', 'question', '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',
'valid_string_length_max', 'valid_string_length_min', 'valid_file_portrait', 'internal_name',)
@@ -682,6 +682,7 @@ class QuestionnaireSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
self.fields['children'] = InlineQuestionnaireChildSerializer(many=True, required=True, context=kwargs['context'], partial=False)
self.fields['limit_sales_channels'].child_relation.queryset = kwargs['context']['event'].organizer.sales_channels.all()
super().__init__(*args, **kwargs)
def validate(self, data):
+12 -5
View File
@@ -463,7 +463,7 @@ with scopes_disabled():
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
fields = ['identifier']
class DatafieldViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -471,8 +471,8 @@ class DatafieldViewSet(ConditionalListView, viewsets.ModelViewSet):
queryset = Question.objects.none()
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
ordering_fields = ('id')
ordering = ('id')
permission = None
write_permission = 'event.items:write'
@@ -568,11 +568,18 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
with scopes_disabled():
class QuestionnaireFilter(FilterSet):
class Meta:
model = Questionnaire
fields = ['type']
class QuestionnaireViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionnaireSerializer
queryset = Questionnaire.objects.none()
#filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
#filterset_class = QuestionFilter
filter_backends = (DjangoFilterBackend, TotalOrderingFilter)
filterset_class = QuestionnaireFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = None
+3 -3
View File
@@ -50,7 +50,7 @@ from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import ProgrammingError
from django.db.models import Prefetch, QuerySet
from django.db.models import Prefetch, Q, QuerySet
from django.forms import Select, widgets
from django.forms.widgets import FILE_INPUT_CONTRADICTION
from django.utils.formats import date_format
@@ -943,8 +943,7 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
"""
Takes two additional keyword arguments:
:param checkoutsession: The checkout session the form should be for
:param order: The order the form should be for
:param container: The checkout session or order the form should be for
:param event: The event this belongs to
"""
request = kwargs.pop('request', None)
@@ -954,6 +953,7 @@ class OrderLevelQuestionsForm(BaseQuestionsForm):
super().__init__(*args, **kwargs)
questionnaires = Questionnaire.objects.filter(
Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=container.sales_channel.identifier),
event=event, type=Questionnaire.QuestionnaireType.ORDER_SALE,
).order_by('position').prefetch_related(
Prefetch('children', QuestionnaireChild.objects.prefetch_related(
+26 -26
View File
@@ -1671,37 +1671,37 @@ class Question(LoggedModel):
),
],
)
help_text = I18nTextField(
# TODO(questionnaires) : to be removed
verbose_name=_("Help text"),
help_text=_("If the question needs to be explained or clarified, do it here!"),
null=True, blank=True,
)
#help_text = I18nTextField(
# # TODO(questionnaires) : to be removed
# verbose_name=_("Help text"),
# help_text=_("If the question needs to be explained or clarified, do it here!"),
# null=True, blank=True,
#)
type = models.CharField(
max_length=5,
choices=FieldType.choices,
verbose_name=_("Question type")
)
required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
default=False,
verbose_name=_("Required question")
)
items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
Item,
related_name='questions',
verbose_name=_("Products"),
blank=True,
help_text=_('This question will be asked to buyers of the selected products')
)
position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
default=0,
verbose_name=_("Position")
)
ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
verbose_name=_('Ask during check-in instead of in the ticket buying process'),
help_text=_('Not supported by all check-in apps for all question types.'),
default=False
)
#required = models.BooleanField( # TODO(questionnaires) : to be removed, -> QuestionnaireChild
# default=False,
# verbose_name=_("Required question")
#)
#items = models.ManyToManyField( # TODO(questionnaires) : to be removed, -> Questionnaire
# Item,
# related_name='questions',
# verbose_name=_("Products"),
# blank=True,
# help_text=_('This question will be asked to buyers of the selected products')
#)
#position = models.PositiveIntegerField( # TODO(questionnaires) : to be removed, -> Questionnaire + QuestionnaireChild
# default=0,
# verbose_name=_("Position")
#)
#ask_during_checkin = models.BooleanField( # TODO(questionnaires) : to be removed
# verbose_name=_('Ask during check-in instead of in the ticket buying process'),
# help_text=_('Not supported by all check-in apps for all question types.'),
# default=False
#)
show_during_checkin = models.BooleanField(
verbose_name=_('Show answer during check-in'),
help_text=_('Not supported by all check-in apps for all question types.'),
+2 -2
View File
@@ -37,7 +37,7 @@ from pretix.base.forms.questions import (
)
from pretix.base.models import (
CartPosition, InvoiceAddress, OrderPosition, Question, QuestionAnswer,
QuestionnaireChild, QuestionOption,
Questionnaire, QuestionnaireChild, QuestionOption,
)
from pretix.base.models.customers import AttendeeProfile
from pretix.base.models.orders import CheckoutSession, Order
@@ -350,7 +350,7 @@ class OrderQuestionsViewMixin(BaseQuestionsViewMixin):
def positions(self):
qqs = self.request.event.questionnaires.all()
if self.only_user_visible:
qqs = qqs.filter(type='PS')
qqs = qqs.filter(type=Questionnaire.QuestionnaireType.ORDER_POSITION_SALE)
else:
qqs = qqs.filter(type__startswith='P')
qqs = qqs.filter(
-3
View File
@@ -157,7 +157,6 @@ class QuestionForm(I18nModelForm):
del self.fields['show_during_checkin']
del self.fields['print_on_invoice']
self.fields['identifier'].required = False
self.fields['help_text'].widget.attrs['rows'] = 3
def clean_show_during_checkin(self):
val = self.cleaned_data.get('show_during_checkin')
@@ -191,9 +190,7 @@ class QuestionForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'question',
'help_text',
'type',
'required',
'show_during_checkin',
'hidden',
'identifier',
@@ -34,6 +34,9 @@
{% url "control:event.items.questions.edit" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_edit_url %}
{{ datafield_edit_url|json_script:"datafield_edit_url" }}
{% url "control:event.items.questions.show" organizer=request.event.organizer.slug event=request.event.slug question=0 as datafield_view_url %}
{{ datafield_view_url|json_script:"datafield_view_url" }}
{% url "control:event.items.questions.add" organizer=request.event.organizer.slug event=request.event.slug as datafield_create_url %}
{{ datafield_create_url|json_script:"datafield_create_url" }}
+2 -2
View File
@@ -555,7 +555,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
question=self.object, orderposition__isnull=False,
)
qs = qs.filter(orderposition__in=opqs)
op_cnt = opqs.filter(item__in=self.object.items.all()).count()
op_cnt = 0 # TODO opqs.filter(item__in=self.object.items.all()).count()
if self.object.type == Question.TYPE_FILE:
qs = [
@@ -600,7 +600,7 @@ class QuestionView(EventPermissionRequiredMixin, ChartContainingView, DetailView
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx['items'] = self.object.items.exists()
#ctx['items'] = self.object.items.exists()
ctx['has_subevents'] = self.request.event.has_subevents
stats = self.get_answer_statistics()
ctx['stats'], ctx['total'] = stats
@@ -87,6 +87,8 @@ dialog::backdrop {
100% { transform: skewX(0deg); }
}
.modal-card.no-padding, .modal-card.no-padding .modal-card-content { padding: 0; }
.modal-card.no-scroll { overflow: hidden; }
/* Legacy dialogs (still used for #ajaxerr and #popupmodal) */
@@ -1139,10 +1139,12 @@ function notify_parent_frame() {
}, location.origin)
} catch {}
$(function () {
window.parent.postMessage({
type: 'pretix:dialog-loaded',
contentHeight: $('body').height(),
}, location.origin)
setTimeout(() => {
window.parent.postMessage({
type: 'pretix:dialog-loaded',
contentHeight: $('#page-wrapper > .container-fluid').outerHeight() + 20,
}, location.origin)
}, 100)
})
}
notify_parent_frame()
@@ -4,7 +4,10 @@ import {gettext} from "./gettextstub";
import NativeDialog from "./NativeDialog.vue";
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
const props = defineProps(['defaultUrl'])
const props = defineProps({
'defaultUrl': {type: String, default: null},
'maxWidth': {type: String, default: '43em'},
})
const emit = defineEmits(['confirm'])
const dlgDjangoDialog = ref()
const url = ref(props.defaultUrl)
@@ -40,20 +43,20 @@ defineExpose({
frameHeight.value = 400
url.value = newUrl
dlgDjangoDialog.value.show()
}
},
});
</script>
<template>
<NativeDialog ref="dlgDjangoDialog" class="modal-card" no-padding="true" no-scroll="true">
<NativeDialog ref="dlgDjangoDialog" class="modal-card" :no-padding="true" :no-scroll="true" :max-width="maxWidth">
<div :style="{'height': frameHeight + 'px'}">
<i class="fa fa-cog big-rotating-icon" v-if="frameLoading"></i>
<iframe :src="url" v-if="dlgDjangoDialog.visible" :height="frameHeight" :style="{'visibility': frameLoading ? 'hidden' : 'visible'}"></iframe>
<div class="frame-load-indicator" v-if="frameLoading"><i class="fa fa-cog big-rotating-icon"></i></div>
<iframe :src="url" v-if="dlgDjangoDialog.visible" :height="frameHeight" :style="{'opacity': frameLoading ? '0' : '1'}"></iframe>
</div>
</NativeDialog>
</template>
<style scoped>
div { text-align: center; }
iframe { width: 100%; border: 0; }
.frame-load-indicator { text-align: center; }
iframe { width: 100%; border: 0; }
</style>
@@ -8,6 +8,7 @@ import {onMounted, onUnmounted, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
import { ProgressBar } from "./ProgressBar";
const sales_channels_list = await api.getSalesChannels();
const items_list = await api.getItems();
const categories_list = await api.getCategories();
const categories = Object.fromEntries(categories_list.map(cat => [cat.id, cat]));
@@ -34,6 +35,7 @@ async function refreshQuestionnaireList () {
async function refreshDatafieldList () {
order_datafields.value = await api.getDatafields('O')
position_datafields.value = (await api.getDatafields('P')).concat(Object.values(SYSTEM_DATAFIELDS))
console.log('datafield list refreshed')
}
await Promise.all([refreshQuestionnaireList(), refreshDatafieldList()])
@@ -46,14 +48,12 @@ function saveQuestionnaire(questionnaire) {
result = api.createQuestionnaire(questionnaire)
}
result = result.then(d => {
console.log(questionnaire, 'ok')
questionnaire.id = d.id
questionnaire.children = d.children
questionnaire._err_mes = null
questionnaire._loading = false
return d;
}, err => {
console.log(questionnaire, 'err:', err)
questionnaire._err_mes = err
questionnaire._loading = false
return err;
@@ -180,8 +180,8 @@ const preview_mode = ref(false)
transform: rotate(0deg);
}
.questionnaires-list > .editor-action-row { border-top: 1px solid rgb(175 175 175 / 0.3); }
.editor-action-row { padding-top: 10px; width: calc(100% + 190px); padding-right: 170px }
.questionnaires-list > .editor-action-row { border-top: 1px solid rgb(175 175 175 / 0.3); padding-top: 25px; padding-bottom: 10px }
</style>
<template>
<div class="questionnaires-editor" v-if="!preview_mode">
@@ -194,6 +194,7 @@ const preview_mode = ref(false)
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="order_datafields"
:sales_channels="sales_channels_list"
:grouped_items="null"
:selected_product="null"
:preview_mode="false"
@@ -241,6 +242,7 @@ const preview_mode = ref(false)
<QuestionnaireElement v-for="(questionnaire, index) in position_questionnaires.filter(q => q.items.indexOf(selected_product as any) !== -1)"
:questionnaire="questionnaire"
:datafields="position_datafields"
:sales_channels="sales_channels_list"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="true"
@@ -255,6 +257,7 @@ const preview_mode = ref(false)
<QuestionnaireElement
:questionnaire="questionnaire"
:datafields="position_datafields"
:sales_channels="sales_channels_list"
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="false"
@@ -8,9 +8,10 @@ const props = defineProps({
type: String,
default: "",
},
title: '',
noPadding: false,
noScroll: true,
title: {type: String, default: ''},
noPadding: {type: Boolean, default: false},
noScroll: {type: Boolean, default: true},
maxWidth: {type: String, default: '43em'},
});
const visible = ref(false);
@@ -34,6 +35,7 @@ const id = useId();
@close="visible = false"
closedby="any"
:aria-labelledby="`${id}-title`"
:style="{maxWidth: props.maxWidth}"
>
<form
v-if="visible"
@@ -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 {getDatafieldCreateUrl, getDatafieldEditUrl} from "./api";
import {getDatafieldCreateUrl, getDatafieldEditUrl, getDatafieldViewUrl} from "./api";
import I18nTextArea from "./I18nTextArea.vue";
import DjangoDialog from "./DjangoDialog.vue";
@@ -16,11 +16,11 @@ const question = ref(props.question);
const dlgEditDatafield = ref()
const df = typeof question.value.question === 'number' ?
const df = computed(() => typeof question.value.question === 'number' ?
props.datafields.find(el => el.id === question.value.question) :
typeof question.value.question === 'string' ?
SYSTEM_DATAFIELDS[question.value.question] :
null;
null);
const dependency_values_options = computed(() => props.datafields.find(el => el.id === question.value.dependency_question)?.options);
const dependency_values_resolved = computed(() => question.value.dependency_values.map(ident => i18n_any(dependency_values_options.value.find(opt => opt.identifier === ident)?.answer) ?? ident));
@@ -109,7 +109,10 @@ const editor = ref();
<p class="form-control-static">
<template v-if="typeof question.question === 'number'">
{{ df.internal_name }}
<a href="javascript:" @click="dlgEditDatafield.open(getDatafieldEditUrl(df.id))">Manage data field details</a>
<div>
<a class="btn btn-sm btn-default" href="javascript:" @click="dlgEditDatafield.open(getDatafieldEditUrl(df.id))"><span class="fa fa-wrench"></span> Manage data field details</a>
<a class="btn btn-sm btn-default" :href="getDatafieldViewUrl(df.id)" target="_blank"><span class="fa fa-external-link"></span> View answers</a>
</div>
</template>
<template v-else>
{{ question.question }}
@@ -161,6 +164,6 @@ const editor = ref();
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
</NativeDialog>
<DjangoDialog ref="dlgEditDatafield" @confirm="emit('invalidate:datafields')"></DjangoDialog>
<DjangoDialog ref="dlgEditDatafield" @confirm="emit('invalidate:datafields')" max-width="60em"></DjangoDialog>
</Teleport>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {useId, ref, computed, onMounted} from 'vue'
import {useId, ref, computed, onMounted, watch} from 'vue'
import QuestionElement from "./QuestionElement.vue";
import {
i18n_any,
@@ -25,12 +25,15 @@ const newTextblockTitle = ref()
const newTextblockText = ref()
const id = useId();
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
const props = defineProps(['questionnaire', 'datafields', 'sales_channels', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
const emit = defineEmits(['update', 'invalidate:datafields'])
for (let qc of props.questionnaire.children) {
if (!qc._cid) qc._cid = useId();
}
let nextId = 1;
watch(() => props.questionnaire.children, () => {
for (let qc of props.questionnaire.children) {
if (!qc._cid) qc._cid = id + (++nextId);
}
})
function setVisibleOnItem (checked, itemId) {
const i = props.questionnaire.items.indexOf(itemId)
@@ -76,12 +79,19 @@ function addTextblock () {
}
function newDatafield (container_type) {
dlgNewDatafield.value.dialog.show()
dlgNewDatafield.value.open(getDatafieldCreateUrl(props.questionnaire.type[0]))
}
async function onNewDatafieldCreated (data) {
await emit('invalidate:datafields')
dlgAddExisting.value.show()
watch(
() => props.datafields,
(newValue, oldValue) => {
console.log('datafields changed watcher called')
addExistingDatafield(newValue.find(f => f.id === data.object))
},
{ once: true }
)
emit('invalidate:datafields')
}
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1)
@@ -97,7 +107,7 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<summary class="questionnaire-panel-heading">
<div class=" editor-row">
<div class="editor-preview-area">
<input type="checkbox" @change="setVisibleOnItem(this.checked, selected_product); emit('update')" v-if="selected_product && !preview_mode" :checked="!isHidden">
<input type="checkbox" @change="e => {setVisibleOnItem(e.target.checked, selected_product); emit('update')}" v-if="selected_product && !preview_mode" :checked="!isHidden">
{{ props.questionnaire.internal_name }}
<span class="fa fa-warning" v-if="questionnaire._err_mes"></span>
<span class="fa fa-cog fa-spin" v-if="questionnaire._loading"></span>
@@ -170,6 +180,14 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<input type="checkbox" v-model="questionnaire.all_sales_channels"> {{ gettext('All sales channels') }}
</label>
</div>
<div class="checkbox" v-for="channel in sales_channels">
<label>
<input type="checkbox" :checked="questionnaire.all_sales_channels || questionnaire.limit_sales_channels.indexOf(channel.identifier) !== -1"
@change="e => e.target.checked ? questionnaire.limit_sales_channels.push(channel.identifier) : questionnaire.limit_sales_channels.splice(questionnaire.limit_sales_channels.indexOf(channel.identifier), 1)"
:disabled="questionnaire.all_sales_channels">
{{ i18n_any(channel.label) }}
</label>
</div>
</div>
</div>
<div class="form-group" v-if="grouped_items">
@@ -181,7 +199,7 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<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" @change="setVisibleOnItem(this.checked, item.id)"> {{ item.internal_name || i18n_any(item.name) }}
<input :id="id + '_' + item.id" type="checkbox" :checked="questionnaire.items.indexOf(item.id) !== -1" @change="e => setVisibleOnItem(e.target.checked, item.id)"> {{ item.internal_name || i18n_any(item.name) }}
</label>
</div>
</div>
@@ -226,6 +244,6 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
</NativeDialog>
<DjangoDialog ref="dlgNewDatafield" :default-url="getDatafieldCreateUrl(questionnaire.type[0])" @confirm="onNewDatafieldCreated"></DjangoDialog>
<DjangoDialog ref="dlgNewDatafield" @confirm="onNewDatafieldCreated" max-width="60em"></DjangoDialog>
</Teleport>
</template>
@@ -71,10 +71,17 @@ export async function getCategories() {
return await api_get_all<Category>(`organizers/${organizer_slug}/events/${event_slug}/categories/`);
}
export async function getSalesChannels() {
return await api_get_all<Category>(`organizers/${organizer_slug}/saleschannels/`);
}
export function getEventLocales() {
return fromJsonScript('event_locales');
}
export function getDatafieldViewUrl(datafield_id) {
return fromJsonScript('datafield_view_url').replace('/0/', `/${datafield_id}/`);
}
export function getDatafieldEditUrl(datafield_id) {
return fromJsonScript('datafield_edit_url').replace('/0/', `/${datafield_id}/`) + '?notify_parent=true&';
}
@@ -900,6 +900,7 @@ tbody th {
html.in-iframe body { background: white }
.in-iframe #wrapper > nav.navbar, .in-iframe #page-wrapper > div > footer { display: none; }
.in-iframe #page-wrapper { padding: 0 15px; min-height: 568px; border-left: 0; margin: 0; }
@import "../../pretixbase/scss/_rtl.scss";
@import "../../bootstrap/scss/_rtl.scss";