mvp (file)settings
This commit is contained in:
Kara Engelhardt
2026-08-12 13:21:48 +02:00
parent a82d3178e2
commit 4730241d76
21 changed files with 480 additions and 175 deletions
+25
View File
@@ -0,0 +1,25 @@
from pretix.base.models import (
CachedFile
)
from rest_framework.exceptions import ValidationError
from django.core.exceptions import ValidationError as DjangoValidationError
from django.conf import settings
def handle_file_upload(data, user, auth, allowed_types):
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
except (ValidationError, DjangoValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file
+8 -23
View File
@@ -63,7 +63,7 @@ from pretix.api.views import RichOrderingFilter
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,
Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
)
from pretix.base.models.orders import PrintLog
@@ -75,6 +75,7 @@ from pretix.base.services.checkin import (
from pretix.base.services.media import perform_media_exchange
from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF
from pretix.api.helpers import handle_file_upload
with scopes_disabled():
class CheckinListFilter(FilterSet):
@@ -328,27 +329,6 @@ with scopes_disabled():
)
def _handle_file_upload(data, user, auth):
try:
cf = CachedFile.objects.get(
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
file__isnull=False,
pk=data[len("file:"):],
)
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
except CachedFile.DoesNotExist:
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
allowed_types = (
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
)
if cf.type not in allowed_types:
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
return cf.file
def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_products=False, pdf_data=False, expand=None):
@@ -795,7 +775,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
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)
given_answers[q] = handle_file_upload(
answers_data[str(q.pk)],
user,
auth,
allowed_types=('image/png', 'image/jpeg', 'image/gif', 'application/pdf')
)
else:
given_answers[q] = None
else:
+44 -12
View File
@@ -1,16 +1,17 @@
from rest_framework import viewsets
from rest_framework import viewsets, serializers
from django.db import transaction
from .styles import AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS
from .models import WalletLayout, WalletPlatformLayout
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.fields import UploadedFileField
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from .views import get_editor_placeholders
from rest_framework import serializers
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
platform = serializers.ChoiceField(choices=[p.identifier for p in AVAILABLE_PLATFORMS])
platform = serializers.ChoiceField(
choices=[p.identifier for p in AVAILABLE_PLATFORMS]
)
style = serializers.CharField(allow_null=True, required=False)
class Meta:
@@ -23,9 +24,9 @@ class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
return value
def validate(self, data):
platform = data.get('platform')
style = data.get('style')
layout = data.get('layout')
platform = data.get("platform")
style = data.get("style")
layout = data.get("layout")
if platform and style and layout:
platform_styles = AVAILABLE_STYLES_DICT[platform]
@@ -33,10 +34,24 @@ class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
raise ValidationError(_("Invalid style"))
style = platform_styles[data["style"]]
style = style(event=self.context['event'], layout=data["layout"])
style = style(event=self.context["event"], layout=data["layout"])
style.validate()
data['file_settings'] = style.extract_file_settings(self.context['request'])
return data
def to_representation(self, instance):
ret = super().to_representation(instance)
ret['file_settings'] = {}
for file_setting in instance.file_settings.all():
try:
url = file_setting.file.url
except AttributeError:
continue
request = self.context['request']
ret['file_settings'][file_setting.key] = request.build_absolute_uri(url)
return ret
class WalletLayoutSerializer(I18nAwareModelSerializer):
platform_layouts = WalletPlatformLayoutSerializer(many=True)
@@ -53,11 +68,28 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
super().save(*args, **kwargs, event=self.context["event"])
def update(self, instance, validated_data):
platform_layouts = validated_data.pop('platform_layouts')
platform_layouts = validated_data.pop("platform_layouts")
for layout in platform_layouts:
if layout['style']:
instance.platform_layouts.update_or_create(platform=layout['platform'], defaults=layout)
instance.platform_layouts.exclude(platform__in={layout['platform'] for layout in platform_layouts if layout['style'] is not None}).delete()
if layout["style"]:
# TODO: better handling here
file_settings = layout.pop("file_settings", {})
obj, _ = instance.platform_layouts.update_or_create(
platform=layout["platform"], defaults=layout
)
for key, file in file_settings.items():
if not file:
obj.file_settings.filter(key=key).delete()
elif file != "keep":
obj.file_settings.update_or_create(key=key, defaults={"file": file})
instance.platform_layouts.exclude(
platform__in={
layout["platform"]
for layout in platform_layouts
if layout["style"] is not None
}
).delete()
return super().update(instance, validated_data)
@@ -0,0 +1,35 @@
# Generated by Django 5.2.13 on 2026-08-11 15:39
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("wallet", "0002_alter_walletlayoutitem_unique_together_and_more"),
]
operations = [
migrations.CreateModel(
name="WalletLayoutFileSetting",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("key", models.CharField()),
("file", models.FileField(upload_to="")),
(
"layout",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="file_settings",
to="wallet.walletplatformlayout",
),
),
],
),
]
+8 -1
View File
@@ -70,7 +70,9 @@ class WalletPlatformLayout(LoggedModel):
style = get_style(self.platform, self.style)
if style:
return style(event=self.parent.event, layout=self.layout)
file_settings = dict(self.file_settings.values_list("key", "file"))
print(file_settings)
return style(event=self.parent.event, layout=self.layout, file_settings=file_settings)
else:
raise RuntimeError(f"Style {self.platform}.{self.style} not found")
@@ -83,6 +85,11 @@ class WalletLayoutItem(models.Model):
if self.item.event != self.layout.event:
raise ValidationError("cannot bind layout to item of different event")
class WalletLayoutFileSetting(models.Model):
layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.CASCADE, related_name="file_settings")
key = models.CharField()
file = models.FileField()
# smth like this for apple, lets see what the best architecture for google will be
# class AppleWalletPass(models.Model):
# platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT)
@@ -70,7 +70,6 @@ const preview_layout = computed(() => {
.tab-pane.active.row
.col-md-6.col-lg-8
Select.form-group(label="Style" :modelValue="store.currentPlatformLayout.style" @update:modelValue="store.setCurrentPlatformStyle" :choices="platformChoices")
StyleSettings(v-if="store.currentPlatformLayout.style" v-model="store.currentPlatformLayout.layout" :style="store.currentPlatformStyles[store.currentPlatformLayout.style]")
.col-md-6.col-lg-4
.panel.panel-default
@@ -0,0 +1,53 @@
<script setup lang="ts">
import { useId } from "vue";
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false,
});
const {
label,
errors,
} = defineProps<{
label?: I18nString;
errors?: string[];
help_text?: string;
}>();
const modelValue = defineModel<string | File | null>();
const id = useId();
function onChange(e) {
modelValue.value = (e.target as HTMLInputElement).files[0]
}
</script>
<template lang="pug">
.form-group.row
label.control-label.col-md-3(:for="id", v-if="!!label") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div.col-md-9
template(v-if="typeof modelValue == 'string'")
| {{ gettext("Currently") + ': ' }}
//- TODO: preview filename
a(:href="modelValue") FILENAME
| {{ " " }}
button.btn.btn-sm(@click.prevent="() => {console.log('clear'); modelValue = null}") {{ gettext("Clear") }}
//- br
//- a(:href="modelValue" data-lightbox="input")
//- img.thumb-img(:src="modelValue")
br
| {{ gettext("Change") + ': ' }}
input(:id="id" @change="onChange" v-bind="$attrs" type="file" style="display: inline")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
<style lang="css" scoped>
.thumb-img {
max-height: 100px;
max-width: 200px;
object-fit: contain;
}
</style>
@@ -1,20 +1,28 @@
<script setup lang="ts">
import { useId } from 'vue'
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string,
const {label, errors, type = "text"} = defineProps<{
label?: I18nString,
errors?: string[],
type?: string
help_text?: string,
}>()
const modelValue = defineModel<string|null>();
const id = useId()
</script>
<template lang="pug">
label.control-label(:for="id", v-if="props.label") {{ props.label }}
input.form-control(:id="id" v-model="modelValue" v-bind="$attrs")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
label.control-label(:for="id", v-if="label") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div
input(:id="id" v-model="modelValue" v-bind="$attrs" :type="type" :class="{'form-control': type == 'text'}")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
@@ -6,7 +6,7 @@ import { i18nstringLocalize } from "../../helpers";
const store = inject(StoreKey)!;
const props = defineProps<{
label: I18nString;
label?: I18nString;
content: I18nString;
content_type: FieldContentType;
display: FieldGroupDisplay;
@@ -17,9 +17,16 @@ const style_def = computed(() => {
].fieldgroups.map((x) => [x.identifier, x]),
)[props.config.fieldgroup];
});
function focusGroup() {
// TODO: highlight group? or change ui concept completely
const elem = document.getElementById('fieldgroup-' + props.config.fieldgroup)
elem && elem.scrollIntoView()
}
</script>
<template lang="pug">
div(@click="focusGroup")
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def")
PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
</template>
@@ -1,58 +0,0 @@
<script setup lang="ts">
const props = defineProps<{
config: {value: string; label?: string; display?: string};
}>();
</script>
<template lang="pug">
div.fieldgroup-item
div.fieldgroup-label.nowrap(v-if="!!config.label") {{ config.label }}
div.nowrap.content(:class="config.display") {{ config.value }}
</template>
<style lang="css" scoped>
.fieldgroup-label {
font-weight: bold;
font-size: 0.8em;
}
.fieldgroup-item {
flex: 0 1 100%;
min-width: 0;
width: 100%;
display: flex;
flex-direction: column;
justify-content: center;
}
.fieldgroup-item-image {
max-height: 3lh;
}
.fieldgroup-item-qrcode {
font-weight: bold;
background-color: lightgray;
width: 50%;
aspect-ratio: 1;
margin: auto;
/* center content */
display: flex;
align-items: center;
text-align: center;
padding: 1em;
overflow-wrap: anywhere;
}
.nowrap {
text-overflow: ellipsis;
overflow: hidden;
text-wrap: nowrap;
}
.bold {
font-weight: bold;
}
.large {
font-size: 1.5em;
}
.tight {
line-height: 1;
}
</style>
@@ -59,6 +59,7 @@ const props = defineProps<{ layout: Array<PreviewLayout> }>();
}
.fieldgroup-item-image {
max-height: 3lh;
object-fit: contain;
}
.fieldgroup-item-qrcode {
font-weight: bold;
@@ -1,7 +1,7 @@
<script setup lang="ts">
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
import FieldgroupPreview from './fieldgroup-preview.vue';
import FixedPreview from './fixed-preview.vue';
import SettingPreview from './setting-preview.vue';
const props = defineProps<{
config: PreviewRow;
}>();
@@ -12,7 +12,8 @@ const props = defineProps<{
div.preview-row(v-if="'children' in config" :style="{ flexDirection: config.direction || 'row' }" :class="config.display")
RowPreview(v-for="child of config.children" :config="child" )
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config")
FixedPreview(v-else-if="'value' in config" :config="config")
FieldgroupItemPreview(v-else-if="'value' in config" :label="config.label" :content="config.value" content_type="text" :display="!!config.label ? 'with_label' : 'plain'" :display_class="config.display")
SettingPreview(v-else-if="'setting' in config" :config="config")
</template>
<style lang="css" scoped>
@@ -0,0 +1,42 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore";
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
const store = inject(StoreKey)!;
const { config } = defineProps<{
config: SettingPreview;
}>();
const settingDef = computed(() => {
return Object.fromEntries(
store.currentPlatformStyles[store.currentPlatformLayout.style].settings.map(
(x) => [x.identifier, x],
),
)[config.setting];
});
const settingValue = computed(() => {
const val = store.currentLayoutSettings[config.setting];
if (settingDef.value.type == "image" && val instanceof File) {
return URL.createObjectURL(val);
} else {
return val;
}
});
</script>
<template lang="pug">
//- pre
//- code {{ settingDef }}
//- pre
//- code {{ config }}
//- pre
//- code {{ settingValue }}
//- PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def")
//- PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(:content="settingValue" :content_type="settingDef.type" :display_class="config.display" display="plain")
</template>
@@ -0,0 +1,36 @@
<script setup lang="ts">
import Input from "./input/input.vue";
import FileInput from "./input/file-input.vue";
import { inject } from "vue";
import { StoreKey } from "../walletStore";
const gettext = (window as any).gettext;
const props = defineProps<{
field?: Setting;
}>();
const store = inject(StoreKey)!;
// function uploadFile(e: Event) {
// // TODO: only store ref, upload when saving, proper handling in store
// const [file] = (e.target as HTMLInputElement).files;
// store.setSetting(props.field.identifier, file)
// // const settings = store.currentPlatformLayout.layout.settings
// // const identifier = props.field.identifier;
// // fetch("/api/v1/upload", {
// // method: "POST",
// // body: file,
// // headers: {
// // "content-disposition": `attachment; filename="${encodeURI(file.name)}"`,
// // "content-type": file.type || "application/octet-stream",
// // "X-CSRFToken": store.csrfToken,
// // },
// // }).then(x => x.json()).then(x=>settings[identifier] = x.id);
// }
</script>
<template lang="pug">
div.form-group
Input(v-if='field.type == "text"' :label="field.label" :type="field.type" :required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)" :modelValue="store.currentLayoutSettings[field.identifier]" :help_text="field.help_text")
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required" :help_text="field.help_text" @update:modelValue="(v) => store.setSetting(field.identifier, v)" :modelValue="store.currentLayoutSettings[field.identifier]")
</template>
@@ -2,7 +2,7 @@
import { computed, inject, watchEffect } from "vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue";
import SettingsField from "./settings-field.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
@@ -13,9 +13,11 @@ const layout = defineModel<LayoutData>();
</script>
<template lang="pug">
h2.h3 {{ gettext("Settings") }}
SettingsField(v-for="field of style.settings" :field="field" :key="field.identifier")
h2.h3 {{ gettext("Field Groups") }}
template(v-if="props.style && layout.fieldgroups"
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups")
div(v-if="props.style && layout.fieldgroups"
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
@@ -44,10 +44,17 @@ type CustomFieldEntry = {
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
type Setting = {
identifier: string;
label: I18nString;
type: "text" | "image";
required: boolean;
};
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
settings: Setting[]
};
type Variable = {
@@ -82,7 +89,8 @@ type FieldGroupConfig =
| PredefinedFieldGroupConfig;
type LayoutData = {
fieldgroups: Record<string, FieldGroupConfig>;
fieldgroups?: Record<string, FieldGroupConfig>;
settings?: Record<string, any>
};
type PlatformLayout = {
@@ -107,18 +115,20 @@ type PreviewLayout = Array<PreviewRow>;
type PreviewRow =
| { children: Array<PreviewRow>; direction?: "row" | "column"; display?: Array<string>;}
| PreviewFieldgroup
| FixedPreview;
| FixedPreview
| SettingPreview;
type PreviewProps = {
relSize?: number;
direction?: "row" | "column";
display?: Array<string>;
}
type SettingPreview = {
setting: string;
} & PreviewProps
type PreviewFieldgroup = PredefinedFieldgroupPreview | PlaceholderFieldGroupPreview;
type FixedPreview = {
value: I18nString;
} & PreviewProps
type FixedPreview = {value: I18nString; label?: I18nString; } & PreviewProps
type PlaceholderFieldGroupPreview = {
fieldgroup: string;
@@ -1,6 +1,6 @@
import { i18nstringLocalize } from "./helpers.js";
import { createStore } from "./lib/store.ts";
import { nextTick, type InjectionKey } from "vue";
import { toRaw, type InjectionKey } from "vue";
export type WidgetStore = ReturnType<typeof createWalletStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
@@ -11,7 +11,6 @@ export function createWalletStore(config: {
locales: Record<string, string>;
csrfToken: string;
layoutId: string;
// platform_layouts: Record<string, PlatformLayout>;
}) {
return createStore({
state: () => ({
@@ -19,6 +18,7 @@ export function createWalletStore(config: {
walletLayout: null as WalletLayout | null,
currentPlatform: config.platforms[0].identifier,
loaded: false,
files: {} as Record<string, File>
}),
getters: {
currentPlatformStyles() {
@@ -35,13 +35,19 @@ export function createWalletStore(config: {
}
for (const layout of this.walletLayout.platform_layouts) {
if (layout.platform === this.currentPlatform) {
if (!("fieldgroups" in layout.layout)) {
layout.layout.fieldgroups = {};
}
if (!("settings" in layout.layout)) {
layout.layout.settings = {};
}
return layout;
}
}
const newLayout = {
platform: this.currentPlatform,
style: null,
layout: { fieldgroups: {} },
layout: { fieldgroups: {}, settings: {} },
};
this.walletLayout.platform_layouts.push(newLayout);
return newLayout;
@@ -55,13 +61,14 @@ export function createWalletStore(config: {
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
content[fieldgroup.identifier] = [];
const layout_group: PlaceholderFieldGroupConfig =
this.currentPlatformLayout.layout.fieldgroups[
fieldgroup.identifier
];
const layout_group = this.currentPlatformLayout.layout.fieldgroups[
fieldgroup.identifier
] as any as PlaceholderFieldGroupConfig;
for (const entry of layout_group.entries) {
const placeholder =
entry.type === "placeholder" ? this.variables[fieldgroup.content_type][entry.content] : null;
entry.type === "placeholder"
? this.variables[fieldgroup.content_type][entry.content]
: null;
let label = i18nstringLocalize(entry.label);
if (placeholder && !label) {
@@ -72,7 +79,9 @@ export function createWalletStore(config: {
if (entry.type == "custom") {
value = i18nstringLocalize(entry.content);
} else if (entry.type == "placeholder") {
value = placeholder?.sample || `(unknown placeholder: ${entry.content})`;
value =
placeholder?.sample ||
`(unknown placeholder: ${entry.content})`;
}
content[fieldgroup.identifier].push({
entry,
@@ -107,6 +116,9 @@ export function createWalletStore(config: {
return content;
},
currentLayoutSettings() {
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
},
},
actions: {
load() {
@@ -120,7 +132,40 @@ export function createWalletStore(config: {
this.loaded = true;
});
},
saveLayout() {
async uploadFile(file: File) {
return await fetch("/api/v1/upload", {
method: "POST",
body: file,
headers: {
"content-disposition": `attachment; filename="${encodeURI(file.name)}"`,
"content-type": file.type || "application/octet-stream",
"X-CSRFToken": this.csrfToken,
},
})
.then((x) => x.json())
.then((x) => x.id);
},
async saveLayout() {
const layoutToSave = structuredClone(toRaw(this.walletLayout))
// TODO: error handling, parallelization
for (const platformLayout of layoutToSave.platform_layouts) {
const platformStyle = this.platforms.filter(
(x) => x.identifier == platformLayout.platform,
)[0].styles[platformLayout.style];
for (const setting of platformStyle.settings) {
console.log(setting, platformLayout.layout?.settings[setting.identifier])
if (
setting.type === "image" &&
platformLayout.layout?.settings[setting.identifier] instanceof
File
) {
platformLayout.layout.settings[setting.identifier] = await
this.uploadFile(
platformLayout.layout.settings[setting.identifier],
);
}
}
}
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
@@ -130,7 +175,7 @@ export function createWalletStore(config: {
"content-type": "application/json",
"X-CSRFToken": this.csrfToken,
},
body: JSON.stringify(this.walletLayout),
body: JSON.stringify(layoutToSave),
},
)
.then((x) => x.json())
@@ -185,29 +230,22 @@ export function createWalletStore(config: {
entries: JSON.parse(
JSON.stringify(newFieldGroups[key].default_entries),
),
active: newFieldGroups[key].required || newFieldGroups[key].default_entries.length > 0
active:
newFieldGroups[key].required ||
newFieldGroups[key].default_entries.length > 0,
};
} else {
this.currentPlatformLayout.layout.fieldgroups[key] = {
active: newFieldGroups[key].required
active: newFieldGroups[key].required,
};
}
}
this.currentPlatformLayout.style = style;
}
// else if (this.currentPlatformLayout.style === null && Object.keys(this.currentPlatformStyles).includes(style)) {
// this.currentPlatformLayout.style = style;
// this.currentPlatformLayout.layout.fieldgroups = {}
// } else if (this.currentPlatformLayout.style !== null && Object.keys(this.currentPlatformStyles).includes(style)) {
// const oldStyle = this.currentPlatformStyles[this.currentPlatformLayout.style];
// const newStyle = this.currentPlatformStyles[style];
// this.currentPlatformLayout.style = style;
// console.log(oldStyle, newStyle)
// }
// this.currentPlatformLayout.style = style;
// }
// if (style == null) { }
},
setSetting(identifier: string, value: string | File | null) {
this.currentPlatformLayout.layout.settings[identifier] = value;
},
},
});
+33 -29
View File
@@ -1,3 +1,4 @@
from collections import OrderedDict
from typing import Any
from .base import (
@@ -10,8 +11,9 @@ from .base import (
WalletPlatform,
PassStyle,
PlaceholderFieldEntry,
SettingsField,
)
from django.utils.translation import gettext as _
from django.utils.translation import gettext as _, gettext_lazy, override
from i18nfield.strings import LazyI18nString
import io
import hashlib
@@ -23,6 +25,7 @@ import json
from django.contrib.staticfiles import finders
from pretix.base.models import OrderPosition
from django.utils.encoding import force_bytes
from django import forms
class ApplePlatform(WalletPlatform):
@@ -38,6 +41,13 @@ class FormattedLazyI18nString:
def localize(self, language):
return self.base_str.localize(language).format(**self.format_args)
def lazyi18nstring_from_gettext(text: str, locales: set[str]) -> LazyI18nString:
data = {}
for locale in locales:
with override(locale):
data[locale] = _(text)
return LazyI18nString(data)
class StringResource:
entries: dict[str, LazyI18nString | FormattedLazyI18nString]
@@ -131,6 +141,25 @@ class SignedZipFile:
class AppleWalletStyle(PassStyle):
@property
def settings(self):
return [
SettingsField(
identifier="logo",
label=_("Logo"),
type="image",
required=False,
help_text="Will be displayed on the top left corner of the pass"
),
SettingsField(
identifier="icon",
label=_("Icon"),
type="image",
required=False,
help_text="Will be displayed as the file icon"
),
]
def pass_content(self, fields, strings):
raise NotImplementedError()
@@ -180,6 +209,7 @@ class AppleWalletStyle(PassStyle):
pass_json = self.generate_pass_json(fields, op, strings)
print(pass_json)
breakpoint()
if fields["logo"]:
logo = fields["logo"][0]["value"]
else:
@@ -204,32 +234,6 @@ class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout 1")
fieldgroups = [
ImageFieldGroup(
identifier="icon",
name=_("Icon"),
min_entries=0,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
content="poweredby",
)
],
required=True,
context_args={"event", "order", "order_position"},
),
ImageFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=0,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
content="poweredby",
)
],
required=True,
context_args={"event", "order", "order_position"},
),
TextFieldGroup(
identifier="logo_text",
name=_("Logo text"),
@@ -248,7 +252,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item",
)
], # TODO: support Lazyi18nproxy here
], # TODO: support Lazyi18nproxy here by using lazyi18nstring_from_gettext
description=_("These fields appear prominently featured on the pass."),
required=True,
context_args={"event", "order", "order_position"},
@@ -293,7 +297,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
[
{
"children": [
{"fieldgroup": "logo", "relSize": 1},
{"setting": "logo"},
{
"fieldgroup": "logo_text",
"relSize": 3,
+75 -5
View File
@@ -1,11 +1,13 @@
import enum
from typing import TypedDict
from typing import Literal, OrderedDict, TypedDict
from i18nfield.strings import LazyI18nString
import jsonschema
from django.core.exceptions import ValidationError
from pretix.base.models import OrderPosition
from ..placeholders import WalletPlaceholderContext, get_wallet_placeholders
from django import forms
from pretix.api.helpers import handle_file_upload
from django.core.files import File
class WalletPlatform:
identifier: str
@@ -253,6 +255,37 @@ class ImageFieldGroup(PlaceholderFieldGroup):
super().__init__(content_type=self.content_type, display=self.display, **kwargs)
class SettingsField:
identifier: str
label: str
type: Literal["image", "text"]
help_text: str|None
required: bool
def __init__(
self,
identifier: str,
label: str,
type: Literal["image", "text"] = "text",
help_text = None,
required: bool = False,
):
self.identifier = identifier
self.label = label
self.type = type
self.help_text = help_text
self.required = required
def asdict(self):
return {
"identifier": self.identifier,
"label": self.label,
"type": self.type,
"help_text": self.help_text,
"required": self.required,
}
class PassStyle:
identifier: str # unique within platform
name: str
@@ -261,6 +294,10 @@ class PassStyle:
# we evaluate the fields in this order, so they overspill in this order as well
fieldgroups: list[FieldGroup]
@property
def settings(self) -> list[SettingsField]:
return []
@property
def preview_layout(self) -> list | None:
return None
@@ -271,10 +308,12 @@ class PassStyle:
"name": self.name,
"fieldgroups": [x.asdict() for x in self.fieldgroups],
"preview_layout": self.preview_layout,
"settings": [x.asdict() for x in self.settings],
}
def layout_schema(self):
context = LayoutContext(placeholders=self.placeholders)
print(f"schema {self.settings=}")
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
# TODO: $id
@@ -293,6 +332,16 @@ class PassStyle:
"required": [
group.identifier for group in self.fieldgroups if group.required
],
},
"settings": {
"type": "object",
"properties": {
setting.identifier: {"type": ["string", "null"]}
for setting in self.settings
},
"required": [
setting.identifier for setting in self.settings if setting.required
],
}
},
"$defs": {
@@ -305,7 +354,11 @@ class PassStyle:
},
}
if any(group.required for group in self.fieldgroups):
schema["required"] = ["fieldgroups"]
schema.setdefault("required", [])
schema["required"].append("fieldgroups")
# if any(setting.required for setting in self.settings):
# schema.setdefault("required", [])
# schema["required"].append("settings")
return schema
@@ -318,10 +371,11 @@ class PassStyle:
return None, None
def __init__(self, event, layout):
def __init__(self, event, layout = None, file_settings: dict[str, File] | None = None):
self.event = event
self.layout = layout
self.placeholders = get_wallet_placeholders(self.event)
self.file_settings = file_settings
def validate(self):
schema = self.layout_schema()
@@ -330,6 +384,22 @@ class PassStyle:
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))
def extract_file_settings(self, request):
file_settings = {}
for setting in self.settings:
if setting.type == "image":
if self.layout.get("settings", {}).get(setting.identifier) == "file:keep":
file_settings[setting.identifier] = "keep"
elif data := self.layout.get("settings", {}).get(setting.identifier):
file_settings[setting.identifier] = handle_file_upload(data, request.user, request.auth, {"image/png", "image/jpeg"})
del self.layout["settings"][setting.identifier]
print(self.layout['settings'])
elif setting.identifier in self.layout.get("settings", {}):
file_settings[setting.identifier] = None
del self.layout["settings"][setting.identifier]
return file_settings
def get_pass_fields(self, op: OrderPosition):
context = WalletPlaceholderContext(
event=self.event, order=op.order, order_position=op
@@ -380,7 +450,7 @@ class PassStyle:
return fields
def group_is_active(self, identifier: str):
return self.layout['fieldgroups'].get(identifier, {}).get("active", False)
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
def generate(self, op: OrderPosition):
raise NotImplementedError()
+12 -4
View File
@@ -10,6 +10,7 @@ from django.urls import reverse
from django.http import HttpResponse, HttpResponseRedirect
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, ListView, DeleteView, View
from pretix_vrpayment_wero.payment import HttpRequest
from pretix.base.i18n import language
from pretix.base.pdf import get_images, get_variables
from pretix.base.services.tickets import get_preview_position
@@ -29,7 +30,7 @@ from django.contrib.staticfiles import finders
from django.utils.functional import cached_property
from django.templatetags.static import static
from .placeholders import get_wallet_placeholders, WalletPlaceholderContext
from pretix.base.middleware import add_to_response_csp
def get_editor_placeholders(event):
with (
@@ -76,7 +77,7 @@ class LayoutEditorView(LayoutDetailView):
"identifier": platform.identifier,
"name": platform.name,
"styles": {
style.identifier: style(self.request.event, None).asdict()
style.identifier: style(self.request.event).asdict()
for style in AVAILABLE_STYLES.get(platform.identifier)
},
}
@@ -89,7 +90,12 @@ class LayoutEditorView(LayoutDetailView):
}
return context
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
response = super().dispatch(request, *args, **kwargs)
add_to_response_csp(response, {
'img-src': ['blob:'],
})
return response
class WalletLayoutCreateForm(forms.ModelForm):
class Meta:
@@ -171,7 +177,9 @@ class LayoutPreviewView(EventPermissionRequiredMixin, View):
language(request.event.settings.locale, request.event.settings.region),
):
p = get_preview_position(request.event)
layout = style(event=event, layout=layout)
l = style(event=event, layout=layout) # TODO
file_settings = l.extract_file_settings(request)
layout = style(event, layout, file_settings)
layout.validate()
fname, mimet, data = layout.generate(p)