add DjangoDialog / notify_parent - allow to load existing views in iframe

...and some more changes
This commit is contained in:
Mira Weller
2026-09-30 21:03:27 +02:00
parent 49ffd49bd9
commit 14a3056bad
16 changed files with 212 additions and 95 deletions
+1 -1
View File
@@ -878,7 +878,7 @@ class BaseQuestionsForm(forms.Form):
field.answer = answers[0]
if qc.dependency_question_id:
field.widget.attrs['data-question-dependency'] = qc.dependency_question_id
field.widget.attrs['data-question-dependency'] = f"question_{qc.dependency_question.user_datafield_id}" if qc.dependency_question.user_datafield_id else qc.dependency_question.system_datafield
field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(qc.dependency_values))
if datafield.type != 'M':
field.widget.attrs['required'] = qc.required and not self.all_optional
+1 -53
View File
@@ -153,54 +153,12 @@ class QuestionForm(I18nModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance.container_type == Question.ContainerType.ORDERPOSITION:
self.fields['items'].queryset = self.instance.event.items.all()
self.fields['items'].required = True
else:
del self.fields['items']
del self.fields['ask_during_checkin']
if self.instance.container_type != Question.ContainerType.ORDERPOSITION:
del self.fields['show_during_checkin']
del self.fields['print_on_invoice']
self.fields['dependency_question'].widget.attrs['data-container-type'] = self.instance.container_type
self.fields['dependency_question'].queryset = self.instance.event.questions.filter(
type__in=(Question.TYPE_BOOLEAN, Question.TYPE_CHOICE, Question.TYPE_CHOICE_MULTIPLE),
ask_during_checkin=False,
container_type=self.instance.container_type,
)
if self.instance.pk:
self.fields['dependency_question'].queryset = self.fields['dependency_question'].queryset.exclude(
pk=self.instance.pk
)
self.fields['identifier'].required = False
self.fields['dependency_values'].required = False
self.fields['help_text'].widget.attrs['rows'] = 3
def clean_dependency_values(self):
val = self.data.getlist('dependency_values')
return val
def clean_dependency_question(self):
dep = val = self.cleaned_data.get('dependency_question')
if dep:
if dep.ask_during_checkin:
raise ValidationError(_('Question cannot depend on a question asked during check-in.'))
seen_ids = {self.instance.pk} if self.instance else set()
while dep:
if dep.pk in seen_ids:
raise ValidationError(_('Circular dependency between questions detected.'))
seen_ids.add(dep.pk)
dep = dep.dependency_question
return val
def clean_ask_during_checkin(self):
val = self.cleaned_data.get('ask_during_checkin')
if val and self.cleaned_data.get('type') in Question.ASK_DURING_CHECKIN_UNSUPPORTED:
raise ValidationError(_('This type of question cannot be asked during check-in.'))
return val
def clean_show_during_checkin(self):
val = self.cleaned_data.get('show_during_checkin')
@@ -236,13 +194,9 @@ class QuestionForm(I18nModelForm):
'help_text',
'type',
'required',
'ask_during_checkin',
'show_during_checkin',
'hidden',
'identifier',
'items',
'dependency_question',
'dependency_values',
'print_on_invoice',
'valid_number_min',
'valid_number_max',
@@ -259,17 +213,11 @@ class QuestionForm(I18nModelForm):
'valid_datetime_max': SplitDateTimePickerWidget(without_seconds=True),
'valid_date_min': DatePickerWidget(),
'valid_date_max': DatePickerWidget(),
'items': forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
'dependency_values': forms.SelectMultiple,
'help_text': I18nMarkdownTextarea,
}
field_classes = {
'valid_datetime_min': SplitDateTimeField,
'valid_datetime_max': SplitDateTimeField,
'items': ItemMultipleChoiceField,
'dependency_question': SafeModelChoiceField,
}
@@ -7,14 +7,14 @@
{% if question %}
{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}
{% else %}
{% trans "Question" %}
{% trans "Create new data field" %}
{% endif %}
{% endblock %}
{% block inside %}
{% if question %}
<h1>{% blocktrans with name=question.question %}Data field: {{ name }}{% endblocktrans %}</h1>
{% else %}
<h1>{% trans "Data field" %}</h1>
<h1>{% trans "Create new data field" %}</h1>
{% endif %}
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
@@ -114,9 +114,6 @@
</div>
</div>
{% bootstrap_field form.identifier layout="control" %}
{% if form.ask_during_checkin %}
{% bootstrap_field form.ask_during_checkin layout="control" %}
{% endif %}
{% if form.show_during_checkin %}
{% bootstrap_field form.show_during_checkin layout="control" %}
{% endif %}
@@ -0,0 +1,11 @@
{% extends "error.html" %}
{% load i18n %}
{% load eventurl %}
{% load urlreplace %}
{% load static %}
{% block content %}
<h1>{% trans "Please wait..." %}</h1>
{{ notify_info|json_script:"notify_info" }}
<script type="text/javascript" src="{% static "pretixcontrol/js/notify_parent.js" %}"></script>
{% endblock %}
+22 -2
View File
@@ -21,10 +21,15 @@
#
import collections.abc
import warnings
from errno import EMSGSIZE
from django.contrib import messages
from django.core.paginator import (
EmptyPage, PageNotAnInteger, UnorderedObjectListWarning,
)
from django.http.response import HttpResponseRedirect
from django.shortcuts import render
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import edit
@@ -38,7 +43,22 @@ class EventBasedFormMixin:
return kwargs
class CreateView(EventBasedFormMixin, edit.CreateView):
class TellParentFormMixin:
def form_valid(self, form):
result = super().form_valid(form)
if self.request.GET.get('notify_parent') and isinstance(result, HttpResponseRedirect):
message_store = messages.get_messages(self.request)
msgs = [{'level': msg.level_tag, 'message': msg.message} for msg in message_store]
#message_store._queued_messages = []
return render(self.request, 'pretixcontrol/notify_parent.html', {
'notify_info': {'object': self.object.pk, 'redirect_url': result.url, 'messages': msgs},
})
else:
return result
class CreateView(EventBasedFormMixin, TellParentFormMixin, edit.CreateView):
"""
Like Django's default CreateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
@@ -47,7 +67,7 @@ class CreateView(EventBasedFormMixin, edit.CreateView):
pass
class UpdateView(EventBasedFormMixin, edit.UpdateView):
class UpdateView(EventBasedFormMixin, TellParentFormMixin, edit.UpdateView):
"""
Like Django's default UpdateView, but passes the optional event
argument to the form. This is necessary for I18nModelForms to work
@@ -0,0 +1,8 @@
try {
window.parent.postMessage({
type: 'pretix:notify-parent',
data: JSON.parse(document.getElementById('notify_info').textContent),
}, location.origin)
} catch (e) {
console.error('Could not post message to parent.', e)
}
@@ -1121,3 +1121,31 @@ $(function () {
return $(this).find('button:not([type=button]), input[type=submit]').length > 0
}).areYouSure({ message: gettext('You have unsaved changes!') })
})
function notify_parent_frame() {
window.addEventListener('message', function(e) {
if (e.source === window) return
if (e.origin === location.origin && e.data.type === 'pretix:dialog-handshake') {
if (!window.isInDialog) {
window.isInDialog = true
window.document.documentElement.classList.add('in-iframe')
}
}
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loading') {
e.source.postMessage({ type: 'pretix:dialog-handshake' })
}
})
try {
window.parent.postMessage({
type: 'pretix:dialog-loading',
title: document.title,
}, location.origin)
} catch {}
$(function () {
window.parent.postMessage({
type: 'pretix:dialog-loaded',
contentHeight: $('body').height(),
}, location.origin)
})
}
notify_parent_frame()
@@ -0,0 +1,59 @@
<script setup lang="ts">
import {gettext} from "./gettextstub";
import NativeDialog from "./NativeDialog.vue";
import {computed, onMounted, onUnmounted, ref, watch} from "vue";
const props = defineProps(['defaultUrl'])
const emit = defineEmits(['confirm'])
const dlgDjangoDialog = ref()
const url = ref(props.defaultUrl)
const frameHeight = ref(400)
const frameLoading = ref(true)
function messageEvent(e: MessageEvent) {
console.log('messageEvent', e.origin, e.source, e.data)
if (!dlgDjangoDialog.value.visible) return
if (e.origin === location.origin && e.data.type === 'pretix:dialog-loaded') {
frameHeight.value = Math.min(window.innerHeight - 120, e.data.contentHeight|0)
frameLoading.value = false
}
if (e.origin === location.origin && e.data.type === 'pretix:notify-parent') {
dlgDjangoDialog.value.close()
emit('confirm', e.data.data)
if (e.data.data.messages) {
alert(e.data.data.messages.map(m => m.message).join('\n\n'))
}
}
}
onMounted(() => {
window.addEventListener('message', messageEvent)
})
onUnmounted(() => {
window.removeEventListener('message', messageEvent)
})
defineExpose({
dialog: dlgDjangoDialog,
open: (newUrl) => {
frameLoading.value = true
frameHeight.value = 400
url.value = newUrl
dlgDjangoDialog.value.show()
}
});
</script>
<template>
<NativeDialog ref="dlgDjangoDialog" class="modal-card" no-padding="true" no-scroll="true">
<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>
</NativeDialog>
</template>
<style scoped>
div { text-align: center; }
iframe { width: 100%; border: 0; }
</style>
@@ -1,10 +1,10 @@
<script setup lang="ts">
import QuestionnaireElement from './QuestionnaireElement.vue';
import * as api from './api';
import { Questionnaire } from './model';
import {Datafield, Questionnaire} from './model';
import {i18n_any, sort, numericComp, groupBy, SYSTEM_DATAFIELDS} from './helper';
import { gettext } from './gettextstub';
import { ref } from 'vue';
import {onMounted, onUnmounted, ref} from 'vue';
import { SlickList, SlickItem } from 'vue-slicksort';
import { ProgressBar } from "./ProgressBar";
@@ -17,25 +17,38 @@ console.log('items_list', items_list)
const grouped_items = [...groupBy(items_list, item => categories[item.category])]
console.log('grouped_items', grouped_items)
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 order_datafields = ref(await api.getDatafields('O'));
const position_datafields = ref((await api.getDatafields('P')).concat(Object.values(SYSTEM_DATAFIELDS)));
type QuestionnaireMaybeUnsaved = Omit<Questionnaire, 'id'> & { _new_id?: number, id?: number };
const order_questionnaires = ref<QuestionnaireMaybeUnsaved[]>();
const position_questionnaires = ref<QuestionnaireMaybeUnsaved[]>();
const order_datafields = ref<Datafield[]>();
const position_datafields = ref<Datafield[]>();
let lastDataRefresh = 0
async function refreshQuestionnaireList () {
const all_questionnaires: QuestionnaireMaybeUnsaved[] = await api.getQuestionnaires();
order_questionnaires.value = all_questionnaires.filter(q => q.type.startsWith('O'))
position_questionnaires.value = all_questionnaires.filter(q => q.type.startsWith('P'))
lastDataRefresh = Date.now()
}
async function refreshDatafieldList () {
order_datafields.value = await api.getDatafields('O')
position_datafields.value = (await api.getDatafields('P')).concat(Object.values(SYSTEM_DATAFIELDS))
}
await Promise.all([refreshQuestionnaireList(), refreshDatafieldList()])
function saveQuestionnaire(questionnaire) {
let result;
questionnaire._loading = true
if (questionnaire.id) {
result = api.updateQuestionnaire(questionnaire.id, questionnaire);
result = api.updateQuestionnaire(questionnaire.id, questionnaire)
} else {
result = api.createQuestionnaire(questionnaire).then(d => {
questionnaire.id = d.id;
return d;
});
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;
@@ -97,6 +110,16 @@ export default {
}
}
}*/
function windowFocused() {
if (Date.now() - lastDataRefresh > 30000) {
console.log('refreshing to avoid overwriting with older data on edit')
refreshQuestionnaireList()
}
}
onMounted(() => window.addEventListener('focus', windowFocused))
onUnmounted(() => window.removeEventListener('focus', windowFocused))
const selected_product = ref("")
const preview_mode = ref(false)
</script>
@@ -174,7 +197,8 @@ const preview_mode = ref(false)
:grouped_items="null"
:selected_product="null"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)" />
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()" />
</SlickItem>
</SlickList>
<div class="editor-action-row form-horizontal">
@@ -220,7 +244,8 @@ const preview_mode = ref(false)
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="true"
@update="saveQuestionnaire(questionnaire)"/>
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"/>
</details>
</div>
@@ -233,7 +258,8 @@ const preview_mode = ref(false)
:grouped_items="grouped_items"
:selected_product="selected_product"
:preview_mode="false"
@update="saveQuestionnaire(questionnaire)"/>
@update="saveQuestionnaire(questionnaire)"
@invalidate:datafields="refreshDatafieldList()"/>
</SlickItem>
</SlickList>
<div v-if="!preview_mode" class="editor-action-row form-horizontal">
@@ -9,6 +9,8 @@ const props = defineProps({
default: "",
},
title: '',
noPadding: false,
noScroll: true,
});
const visible = ref(false);
@@ -28,7 +30,7 @@ const id = useId();
<template>
<dialog
ref="dialog" class="modal-card"
ref="dialog" :class="`modal-card ${props.noPadding ? 'no-padding' : ''} ${props.noPadding ? 'no-scroll' : ''}`"
@close="visible = false"
closedby="any"
:aria-labelledby="`${id}-title`"
@@ -41,9 +43,14 @@ const id = useId();
}"
>
<div class="modal-card-content">
<h2 :id="`${id}-title`" class="modal-card-title h3">{{ title }}</h2>
<h2 :id="`${id}-title`" class="modal-card-title h3" v-if="title">{{ title }}</h2>
<slot />
</div>
</form>
</dialog>
</template>
<style>
.modal-card.no-padding, .modal-card.no-padding .modal-card-content { padding: 0; }
.modal-card.no-scroll { overflow: hidden; }
</style>
@@ -4,15 +4,18 @@ import NativeDialog from './NativeDialog.vue';
import I18nTextField from './I18nTextField.vue';
import {useId, ref, computed} from 'vue'
import { DragHandle } from 'vue-slicksort';
import {getDatafieldEditUrl} from "./api";
import {getDatafieldCreateUrl, getDatafieldEditUrl} from "./api";
import I18nTextArea from "./I18nTextArea.vue";
import DjangoDialog from "./DjangoDialog.vue";
const id = useId();
const props = defineProps(['question', 'datafields', 'editable', 'possible_dependencies'])
const emit = defineEmits(['removeSelf', 'update']);
const emit = defineEmits(['removeSelf', 'update', 'invalidate:datafields']);
const gettext = (window as any).gettext;
const question = ref(props.question);
const dlgEditDatafield = ref()
const df = typeof question.value.question === 'number' ?
props.datafields.find(el => el.id === question.value.question) :
typeof question.value.question === 'string' ?
@@ -106,7 +109,7 @@ const editor = ref();
<p class="form-control-static">
<template v-if="typeof question.question === 'number'">
{{ df.internal_name }}
<a :href="getDatafieldEditUrl(df.id)" target="_blank">Manage data field details</a>
<a href="javascript:" @click="dlgEditDatafield.open(getDatafieldEditUrl(df.id))">Manage data field details</a>
</template>
<template v-else>
{{ question.question }}
@@ -157,5 +160,7 @@ const editor = ref();
<button @click="editor.close(); emit('update')" class="btn btn-primary pull-right"><span class="fa fa-check"></span> Save and close</button>
<button @click="emit('removeSelf')" class="btn btn-default">Remove from questionnaire</button>
</NativeDialog>
<DjangoDialog ref="dlgEditDatafield" @confirm="emit('invalidate:datafields')"></DjangoDialog>
</Teleport>
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import {useId, ref, computed} from 'vue'
import {useId, ref, computed, onMounted} from 'vue'
import QuestionElement from "./QuestionElement.vue";
import {
i18n_any,
@@ -14,17 +14,19 @@ import I18nTextField from "./I18nTextField.vue";
import NativeDialog from "./NativeDialog.vue";
import { SlickList, SlickItem, DragHandle } from 'vue-slicksort';
import {getDatafieldCreateUrl} from "./api";
import DjangoDialog from "./DjangoDialog.vue";
const dlgEditor = ref()
const dlgAddExisting = ref()
const dlgAddTextblock = ref()
const dlgNewDatafield = ref()
const newTextblockTitle = ref()
const newTextblockText = ref()
const id = useId();
const props = defineProps(['questionnaire', 'datafields', 'selected_product', 'grouped_items', 'preview_mode', 'err_mes'])
const emit = defineEmits(['update'])
const emit = defineEmits(['update', 'invalidate:datafields'])
for (let qc of props.questionnaire.children) {
if (!qc._cid) qc._cid = useId();
@@ -74,7 +76,12 @@ function addTextblock () {
}
function newDatafield (container_type) {
location.href = getDatafieldCreateUrl(container_type)
dlgNewDatafield.value.dialog.show()
}
async function onNewDatafieldCreated (data) {
await emit('invalidate:datafields')
dlgAddExisting.value.show()
}
const isHidden = computed(() => props.selected_product && props.questionnaire.items.indexOf(props.selected_product) === -1)
@@ -98,7 +105,7 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<aside class="editor-action-area"><div class="btn-group">
<DragHandle tag="button" class="btn btn-default" v-if="!preview_mode"><i class="fa fa-arrows"></i></DragHandle>
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-edit"></i></button>
<button class="btn btn-default" @click="dlgEditor.show()"><i class="fa fa-wrench"></i></button>
</div></aside>
</div>
</summary>
@@ -113,7 +120,8 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
:editable="true"
:possible_dependencies="props.questionnaire.children.slice(0, index)"
@remove-self="questionnaire.children.splice(index, 1); emit('update')"
@update="emit('update')"/>
@update="emit('update')"
@invalidate:datafields="emit('invalidate:datafields')"/>
</SlickItem>
</SlickList>
</div>
@@ -217,5 +225,7 @@ const isEditable = computed(() => props.selected_product && props.questionnaire.
<button @click="dlgAddTextblock.close()" class="btn btn-default pull-right">{{ gettext('Cancel') }}</button>
</NativeDialog>
<DjangoDialog ref="dlgNewDatafield" :default-url="getDatafieldCreateUrl(questionnaire.type[0])" @confirm="onNewDatafieldCreated"></DjangoDialog>
</Teleport>
</template>
@@ -76,9 +76,9 @@ export function getEventLocales() {
}
export function getDatafieldEditUrl(datafield_id) {
return fromJsonScript('datafield_edit_url').replace('/0/', `/${datafield_id}/`);
return fromJsonScript('datafield_edit_url').replace('/0/', `/${datafield_id}/`) + '?notify_parent=true&';
}
export function getDatafieldCreateUrl(container_type) {
return fromJsonScript('datafield_create_url') + '?container_type=' + container_type;
return fromJsonScript('datafield_create_url') + '?notify_parent=true&container_type=' + container_type;
}
@@ -898,6 +898,9 @@ tbody th {
height: auto;
}
html.in-iframe body { background: white }
.in-iframe #wrapper > nav.navbar, .in-iframe #page-wrapper > div > footer { display: none; }
@import "../../pretixbase/scss/_rtl.scss";
@import "../../bootstrap/scss/_rtl.scss";
@import "_rtl.scss";
@@ -4,7 +4,7 @@ function questions_toggle_dependent (ev) {
return true
}
let dependency_name = $el.attr('name').split('_')[0] + '_' + $el.attr('data-question-dependency')
let dependency_name = $el.attr('name').split('-')[0] + '-' + $el.attr('data-question-dependency')
let dependency_values = JSON.parse($el.attr('data-question-dependency-values'))
let $dependency_el
+1 -6
View File
@@ -134,14 +134,13 @@ class CategoriesTest(ItemFormTest):
assert not ItemCategory.objects.filter(id=c.id).exists()
class QuestionsTest(ItemFormTest):
class DatafieldsTest(ItemFormTest):
def test_create(self):
doc = self.get_doc('/control/event/%s/%s/questions/add' % (self.orga1.slug, self.event1.slug))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['question_0'] = 'What is your shoe size?'
form_data['type'] = 'N'
form_data['items'] = self.item1.id
doc = self.post_doc('/control/event/%s/%s/questions/add' % (self.orga1.slug, self.event1.slug), form_data)
assert doc.select(".alert-success")
self.assertIn("shoe size", doc.select("#page-wrapper table")[0].text)
@@ -157,7 +156,6 @@ class QuestionsTest(ItemFormTest):
form_data['form-MIN_NUM_FORMS'] = '0'
form_data['form-MAX_NUM_FORMS'] = '1'
form_data['form-0-id'] = o1.pk
form_data['items'] = self.item1.id
form_data['form-0-answer_0'] = 'England'
self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
form_data)
@@ -176,7 +174,6 @@ class QuestionsTest(ItemFormTest):
form_data['form-INITIAL_FORMS'] = '1'
form_data['form-MIN_NUM_FORMS'] = '0'
form_data['form-MAX_NUM_FORMS'] = '1'
form_data['items'] = self.item1.id
form_data['form-0-id'] = o1.pk
form_data['form-0-answer_0'] = 'England'
form_data['form-0-DELETE'] = 'yes'
@@ -196,7 +193,6 @@ class QuestionsTest(ItemFormTest):
form_data['form-INITIAL_FORMS'] = '0'
form_data['form-MIN_NUM_FORMS'] = '0'
form_data['form-MAX_NUM_FORMS'] = '1'
form_data['items'] = self.item1.id
form_data['form-0-id'] = ''
form_data['form-0-answer_0'] = 'Germany'
self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
@@ -211,7 +207,6 @@ class QuestionsTest(ItemFormTest):
c = Question.objects.create(event=self.event1, question="What is your shoe size?", type="N", required=True)
doc = self.get_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id))
form_data = extract_form_fields(doc.select('.container-fluid form')[0])
form_data['items'] = self.item1.id
form_data['question_0'] = 'How old are you?'
doc = self.post_doc('/control/event/%s/%s/questions/%s/change' % (self.orga1.slug, self.event1.slug, c.id),
form_data)