proper fileupload handling, better store
This commit is contained in:
Kara Engelhardt
2026-08-12 16:43:41 +02:00
parent 4730241d76
commit 256316e4bc
14 changed files with 594 additions and 276 deletions
+18 -9
View File
@@ -1,22 +1,24 @@
from rest_framework import viewsets, serializers
from rest_framework import viewsets
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 rest_framework import serializers
import os
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
platform = serializers.ChoiceField(
choices=[p.identifier for p in AVAILABLE_PLATFORMS]
)
style = serializers.CharField(allow_null=True, required=False)
file_settings = serializers.JSONField(default=dict, required=False)
class Meta:
model = WalletPlatformLayout
fields = ("platform", "style", "layout")
fields = ("platform", "style", "layout", "file_settings")
def validate_layout(self, value):
if not isinstance(value, dict):
@@ -36,20 +38,25 @@ class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
style = style(event=self.context["event"], layout=data["layout"])
style.validate()
data['file_settings'] = style.extract_file_settings(self.context['request'])
data["file_settings"] = style.extract_file_settings(
self.context["request"], data.get("file_settings", {})
)
return data
def to_representation(self, instance):
ret = super().to_representation(instance)
ret['file_settings'] = {}
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)
request = self.context["request"]
ret["file_settings"][file_setting.key] = {
"url": request.build_absolute_uri(url),
"name": os.path.basename(file_setting.file.name).split('.', 1)[-1]
}
return ret
@@ -71,7 +78,6 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
platform_layouts = validated_data.pop("platform_layouts")
for layout in platform_layouts:
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
@@ -81,7 +87,10 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
obj.file_settings.filter(key=key).delete()
elif file != "keep":
obj.file_settings.update_or_create(key=key, defaults={"file": file})
obj, _ = obj.file_settings.get_or_create(
key=key
)
obj.file.save(os.path.basename(file.name), file)
instance.platform_layouts.exclude(
platform__in={
@@ -33,24 +33,17 @@ function openPreview(e: Event) {
openForm("../../preview/", {
csrfmiddlewaretoken: store.csrfToken,
platform: store.currentPlatform,
style: store.currentPlatformLayout.style,
layout: JSON.stringify(store.currentPlatformLayout.layout),
style: store.layout.style,
layout: JSON.stringify(store.layout.layout),
});
}
const platformChoices = computed(() => {
return [
[null, "Do not generate pass"],
...Object.values(store.currentPlatformStyles).map((x) => [
x.identifier,
x.name,
]),
...Object.values(store.platform.styles).map((x) => [x.identifier, x.name]),
];
});
const preview_layout = computed(() => {
return store.currentPlatformStyles[store.currentPlatformLayout.style]?.preview_layout;
});
</script>
<template lang="pug">
@@ -59,26 +52,26 @@ const preview_layout = computed(() => {
// TODO: proper spinner
template(v-if="!store.loaded") {{ gettext("Loading...") }}
form(v-else @submit.prevent="store.saveLayout")
form(v-else @submit.prevent="store.save")
.form-group
Input(label="Name" v-model="store.walletLayout.name")
Input(label="Name" v-model="store.name")
nav
ul.nav.nav-tabs
li(v-for="platform in store.platforms" :class="{'active': store.currentPlatform === platform.identifier}")
a(role="tab" @click="store.currentPlatform = platform.identifier") {{ platform.name }}
li(v-for="platform in store.platforms" :class="{'active': store.platform.identifier === platform.identifier}")
a(role="tab" @click="store.setPlatform(platform.identifier)") {{ platform.name }}
.tabbed-form.tab-content
.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]")
Select.form-group(label="Style" :modelValue="store.style?.identifier || null" @update:modelValue="store.setStyle" :choices="platformChoices")
StyleSettings(v-if="!!store.style")
.col-md-6.col-lg-4
.panel.panel-default
.panel-heading Preview
.panel-body
div(v-if="preview_layout")
span.text-muted The preview below is only a rough representation of what the pass might look like. Please check the generated pass.
div(v-if="!!store.style?.preview_layout")
span.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px));")
PassPreview(v-for="layout in preview_layout" :layout="layout")
PassPreview(v-for="layout in store.style.preview_layout" :layout="layout")
div(v-else) Preview not supported
//- pre
//- code {{ store.currentPlatformLayout }}
@@ -92,23 +85,22 @@ const preview_layout = computed(() => {
</template>
<style lang="css">
.walletsettings-panel .panel-heading {
.checkbox {
padding: 0;
margin: 0;
display: inline-block;
input[type=checkbox] {
margin-top: 0;
margin-right: 5px;
margin-left: 0px;
position: relative;
top: 1px;
}
label {
padding-left: 0;
}
}
.checkbox {
padding: 0;
margin: 0;
display: inline-block;
input[type="checkbox"] {
margin-top: 0;
margin-right: 5px;
margin-left: 0px;
position: relative;
top: 1px;
}
label {
padding-left: 0;
}
}
}
</style>
</style>
@@ -1,25 +1,31 @@
<script setup lang="ts">
import { useId } from "vue";
import { ref, useId, watchEffect } from "vue";
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false,
});
const {
label,
errors,
} = defineProps<{
const emit = defineEmits<{change: [File]}>()
const props = defineProps<{
label?: I18nString;
errors?: string[];
help_text?: string;
filename?: string;
current_url?: string;
}>();
const modelValue = defineModel<string | File | null>();
const id = useId();
function onChange(e) {
modelValue.value = (e.target as HTMLInputElement).files[0]
emit("change", (e.target as HTMLInputElement).files[0])
}
// Reset input field if a url is provided
const inputRef = ref<HTMLInputElement>();
watchEffect(() => {
if (props.current_url && inputRef.value?.value) {
inputRef.value.value = '';
}
})
</script>
<template lang="pug">
@@ -28,18 +34,17 @@ function onChange(e) {
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div.col-md-9
template(v-if="typeof modelValue == 'string'")
template(v-if="!!current_url")
| {{ gettext("Currently") + ': ' }}
//- TODO: preview filename
a(:href="modelValue") FILENAME
a(:href="current_url") {{ filename }}
| {{ " " }}
button.btn.btn-sm(@click.prevent="() => {console.log('clear'); modelValue = null}") {{ gettext("Clear") }}
button.btn.btn-sm(@click.prevent="() => {console.log('clear'); emit('change', 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")
input(:id="id" @change="onChange" v-bind="$attrs" type="file" style="display: inline" ref="inputRef")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
@@ -1,5 +1,5 @@
<script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue";
import { computed, inject, watchEffect } from "vue";
import Select from "./input/select.vue";
import Checkbox from "./input/checkbox.vue";
import I18nInput from "./input/i18ninput.vue";
@@ -12,8 +12,8 @@ const props = defineProps<{
const style_def = computed(() => {
return Object.fromEntries(
store.currentPlatformStyles[
store.currentPlatformLayout.style
store.styles[
store.layout.style
].fieldgroups.map((x) => [x.identifier, x]),
)[props.config.fieldgroup];
});
@@ -13,5 +13,5 @@ const props = defineProps<{
<template lang="pug">
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
FieldgroupItemPreview(v-for="{ label, content } of store.currentLayoutFieldContent[config.fieldgroup]" :label="label" :content="content" :content_type="style_def.content_type" :display_class="config.display" :display="style_def.display")
FieldgroupItemPreview(v-for="{ label, content } of store.renderedFieldGroups[config.fieldgroup]" :label="label" :content="content" :content_type="style_def.content_type" :display_class="config.display" :display="style_def.display")
</template>
@@ -12,7 +12,7 @@ const props = defineProps<{
const isActive = computed(
() =>
store.currentPlatformLayout.layout.fieldgroups[props.style_def.identifier]?.active,
store.layout.fieldgroups[props.style_def.identifier]?.active,
);
</script>
@@ -12,13 +12,13 @@ const { config } = defineProps<{
const settingDef = computed(() => {
return Object.fromEntries(
store.currentPlatformStyles[store.currentPlatformLayout.style].settings.map(
store.styles[store.layout.style].settings.map(
(x) => [x.identifier, x],
),
)[config.setting];
});
const settingValue = computed(() => {
const val = store.currentLayoutSettings[config.setting];
const val = store.settings[config.setting];
if (settingDef.value.type == "image" && val instanceof File) {
return URL.createObjectURL(val);
} else {
@@ -4,33 +4,14 @@ 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]")
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.settings[field.identifier]" :help_text="field.help_text")
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required" :help_text="field.help_text" @change="(v) => store.setSetting(field.identifier, v)" :filename="store.settings[field.identifier]?.name" :current_url="store.settings[field.identifier]?.name")
</template>
@@ -1,31 +1,27 @@
<script setup lang="ts">
import { computed, inject, watchEffect } from "vue";
import { inject } from "vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue";
import SettingsField from "./settings-field.vue";
import { StoreKey } from "../walletStore.js";
const gettext = (window as any).gettext;
const props = defineProps<{
style?: Style;
}>();
const layout = defineModel<LayoutData>();
const store = inject(StoreKey)!;
</script>
<template lang="pug">
h2.h3 {{ gettext("Settings") }}
SettingsField(v-for="field of style.settings" :field="field" :key="field.identifier")
SettingsField(v-for="field of store.style.settings" :field="field" :key="field.identifier")
h2.h3 {{ gettext("Field Groups") }}
div(v-if="props.style && layout.fieldgroups"
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
div(v-for="(fieldgroup, fieldgroupId) in store.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup"
:overflows="props.style.fieldgroups.slice(fieldgroupId + 1) \
:overflows="store.style.fieldgroups.slice(fieldgroupId + 1) \
.filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
)
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup")
</template>
@@ -1,3 +1,29 @@
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
settings: Setting[];
};
type Variable = {
label: string;
sample: string;
required_context: string[];
};
type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type Platforms = Platform[];
//
type BaseFieldGroupDefinition = {
type: string;
identifier: string;
@@ -46,38 +72,15 @@ type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
type Setting = {
identifier: string;
label: I18nString;
label: string;
type: "text" | "image";
required: boolean;
help_text: string;
};
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
settings: Setting[]
};
type Variable = {
label: string;
sample: string;
required_context: string[];
};
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type Platforms = Platform[];
type PlaceholderFieldGroupConfig = {
entries: Array<FieldEntry>;
overflow: string | null;
active: boolean;
};
type PredefinedFieldGroupConfig = {
@@ -90,7 +93,7 @@ type FieldGroupConfig =
type LayoutData = {
fieldgroups?: Record<string, FieldGroupConfig>;
settings?: Record<string, any>
settings?: Record<string, any>;
};
type PlatformLayout = {
@@ -113,7 +116,11 @@ type WalletStore = {
type PreviewLayout = Array<PreviewRow>;
type PreviewRow =
| { children: Array<PreviewRow>; direction?: "row" | "column"; display?: Array<string>;}
| {
children: Array<PreviewRow>;
direction?: "row" | "column";
display?: Array<string>;
}
| PreviewFieldgroup
| FixedPreview
| SettingPreview;
@@ -122,13 +129,15 @@ type PreviewProps = {
relSize?: number;
direction?: "row" | "column";
display?: Array<string>;
}
};
type SettingPreview = {
setting: string;
} & PreviewProps
} & PreviewProps;
type PreviewFieldgroup = PredefinedFieldgroupPreview | PlaceholderFieldGroupPreview;
type FixedPreview = {value: I18nString; label?: I18nString; } & PreviewProps
type PreviewFieldgroup =
| PredefinedFieldgroupPreview
| PlaceholderFieldGroupPreview;
type FixedPreview = { value: I18nString; label?: I18nString } & PreviewProps;
type PlaceholderFieldGroupPreview = {
fieldgroup: string;
@@ -142,4 +151,14 @@ type PredefinedFieldgroupPreview = {
type PreviewSample = {
content: I18nString;
label: I18nString;
}
};
type NewPlatformLayout = {
style: string;
fieldgroups: {};
settings: {};
file_settings: Record<string, WalletFile>;
};
type ServerSideFile = { url: string; name: string };
type ClientSideFile = { file: File | null; identifier?: string };
type WalletFile = ServerSideFile | ClientSideFile;
@@ -1,3 +1,4 @@
import { serialize } from "node:v8";
import { i18nstringLocalize } from "./helpers.js";
import { createStore } from "./lib/store.ts";
import { toRaw, type InjectionKey } from "vue";
@@ -5,6 +6,43 @@ import { toRaw, type InjectionKey } from "vue";
export type WidgetStore = ReturnType<typeof createWalletStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
function getDefaultFieldgroupState(
fieldgroup: FieldGroupDefinition,
): FieldGroupConfig {
if (fieldgroup.type == "predefined") {
return { active: fieldgroup.required };
} else if (fieldgroup.type == "placeholder") {
return {
overflow: null,
entries: JSON.parse(JSON.stringify(fieldgroup.default_entries)),
};
}
}
function parseExistingFieldgroupState(
fieldgroup: FieldGroupDefinition,
existing?: FieldGroupConfig,
): FieldGroupConfig {
if (!existing) {
return getDefaultFieldgroupState(fieldgroup);
}
if (fieldgroup.type == "predefined") {
return {
active: "active" in existing ? existing.active : fieldgroup.required,
};
} else if (fieldgroup.type == "placeholder") {
return {
overflow: "overflow" in existing ? existing.overflow : null,
// TODO: check that placeholders are possible
entries: structuredClone(
toRaw(
"entries" in existing ? existing.entries : fieldgroup.default_entries,
),
),
};
}
}
export function createWalletStore(config: {
platforms: Platforms;
variables: VariableConfig;
@@ -15,53 +53,51 @@ export function createWalletStore(config: {
return createStore({
state: () => ({
...config,
walletLayout: null as WalletLayout | null,
currentPlatform: config.platforms[0].identifier,
loaded: false,
files: {} as Record<string, File>
activePlatform: config.platforms[0].identifier,
name: null as string | null,
platformLayouts: {} as Record<string, NewPlatformLayout>,
}),
getters: {
currentPlatformStyles() {
for (const platform of this.platforms) {
if (platform.identifier === this.currentPlatform) {
return platform.styles;
platform() {
return this.getPlatform(this.activePlatform);
},
layout() {
if (!this.loaded) {
return;
}
return this.platformLayouts[this.activePlatform] || null;
},
style(): Style {
if (this.layout) {
return this.platform.styles[this.layout.style];
} else {
return null;
}
},
styles() {
return this.platform.styles;
},
settings() {
const settings = {};
for (const setting of this.style.settings) {
if (setting.type == "text") {
settings[setting.identifier] =
this.layout.settings[setting.identifier];
} else if (setting.type == "image") {
settings[setting.identifier] =
this.layout.file_settings[setting.identifier];
}
}
throw "Unknown platform";
return settings;
},
currentPlatformLayout(): PlatformLayout {
if (!this.walletLayout) {
throw "currentPlatformLayout access before store was loaded";
}
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: {}, settings: {} },
};
this.walletLayout.platform_layouts.push(newLayout);
return newLayout;
},
currentLayoutFieldContent() {
renderedFieldGroups() {
const content = {};
const group_defs =
this.currentPlatformStyles[this.currentPlatformLayout.style]
.fieldgroups;
const group_defs = this.style.fieldgroups;
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
content[fieldgroup.identifier] = [];
const layout_group = this.currentPlatformLayout.layout.fieldgroups[
const layout_group = this.layout.fieldgroups[
fieldgroup.identifier
] as any as PlaceholderFieldGroupConfig;
for (const entry of layout_group.entries) {
@@ -94,7 +130,7 @@ export function createWalletStore(config: {
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
const layout_group: PlaceholderFieldGroupConfig =
this.currentPlatformLayout.layout.fieldgroups[
this.layout.fieldgroups[
fieldgroup.identifier
];
if (
@@ -116,11 +152,15 @@ export function createWalletStore(config: {
return content;
},
currentLayoutSettings() {
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
},
},
actions: {
getPlatform(identifier: string): Platform {
for (const platform of this.platforms) {
if (platform.identifier === identifier) {
return platform;
}
}
},
load() {
// TODO: error handling / proper api client
fetch(
@@ -128,44 +168,134 @@ export function createWalletStore(config: {
)
.then((x) => x.json())
.then((x) => {
this.walletLayout = x;
this.parseServerLayout(x);
this.loaded = true;
});
})
.catch(alert);
},
async uploadFile(file: File) {
parseServerLayout(serverLayout) {
this.name = serverLayout.name;
for (const layout of serverLayout.platform_layouts) {
const clientLayout = {
style: layout.style,
fieldgroups: {},
settings: layout.layout.settings,
file_settings: layout.file_settings,
};
const styleDefinition = this.getPlatform(layout.platform).styles[
layout.style
];
console.log(styleDefinition);
for (const fieldgroup of styleDefinition.fieldgroups) {
clientLayout.fieldgroups[fieldgroup.identifier] =
parseExistingFieldgroupState(
fieldgroup,
layout.layout.fieldgroups[fieldgroup.identifier],
);
}
this.platformLayouts[layout.platform] = clientLayout;
}
},
setPlatform(platform: string) {
this.activePlatform = platform;
},
setStyle(style: string | null) {
if (style === null) {
delete this.platformLayouts[this.activePlatform];
} else if (Object.keys(this.platform.styles).includes(style)) {
const newLayout = {
style,
fieldgroups: {},
settings: {},
file_settings: {},
};
for (const fieldgroup of this.platform.styles[style].fieldgroups) {
newLayout.fieldgroups[fieldgroup.identifier] =
getDefaultFieldgroupState(fieldgroup);
}
this.platformLayouts[this.activePlatform] = newLayout;
// TODO: keep old fieldgroups & settings if matching
}
},
getSetting(identifier: string): Setting {
for (const setting of this.style.settings) {
if (setting.identifier === identifier) {
return setting;
}
}
},
setSetting(identifier: string, value: string | File) {
const setting = this.getSetting(identifier);
if (!setting) return;
if (
setting.type === "image" &&
(value instanceof File || value === null)
) {
this.layout.file_settings[setting.identifier] = {
file: value as File | null,
};
} else if (setting.type == "text" && typeof value == "string") {
this.layout.settings[setting.identifier] = value;
}
},
async uploadFile(file: ClientSideFile) {
return await fetch("/api/v1/upload", {
method: "POST",
body: file,
body: file.file,
headers: {
"content-disposition": `attachment; filename="${encodeURI(file.name)}"`,
"content-type": file.type || "application/octet-stream",
"content-disposition": `attachment; filename="${encodeURI(file.file.name)}"`,
"content-type": file.file.type || "application/octet-stream",
"X-CSRFToken": this.csrfToken,
},
})
.then((x) => x.json())
.then((x) => x.id);
.then((x) => {
file.identifier = x.id;
return x.id;
})
.catch(alert);
},
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],
);
}
}
}
async serializePlatformLayout(platform, layout: NewPlatformLayout) {
const uploadedFiles = Object.fromEntries(
(
await Promise.all(
Object.entries(layout.file_settings).map(async ([k, v]) => {
if ("url" in v) {
return;
} else if ("identifier" in v) {
return [k, v.identifier];
} else if ("file" in v && v.file instanceof File) {
return [k, await this.uploadFile(v)];
} else if ("file" in v && v.file == null) {
return [k, null];
}
}),
)
).filter((x) => !!x),
);
return {
platform,
style: layout.style,
file_settings: uploadedFiles,
layout: {
fieldgroups: layout.fieldgroups,
settings: layout.settings,
},
};
},
async serializeLayout() {
const layoutPromises = Object.entries(this.platformLayouts).map(
([platform, layout]) =>
this.serializePlatformLayout(platform, layout),
);
return {
name: this.name,
platform_layouts: await Promise.all(layoutPromises),
};
},
async save() {
const serializedLayout = await this.serializeLayout();
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
@@ -175,78 +305,15 @@ export function createWalletStore(config: {
"content-type": "application/json",
"X-CSRFToken": this.csrfToken,
},
body: JSON.stringify(layoutToSave),
body: JSON.stringify(serializedLayout),
},
)
.then((x) => x.json())
.catch((x) => alert(x))
.then((x) => {
this.walletLayout = x;
this.parseServerLayout(x);
});
},
setCurrentPlatformStyle(style: string | null) {
if (style === null) {
this.currentPlatformLayout.style = null;
this.currentPlatformLayout.layout.fieldgroups = {};
} else if (Object.keys(this.currentPlatformStyles).includes(style)) {
const oldStyle =
this.currentPlatformLayout.style !== null
? this.currentPlatformStyles[this.currentPlatformLayout.style]
: { fieldgroups: [] };
const newStyle = this.currentPlatformStyles[style];
const oldFieldGroups = Object.fromEntries(
oldStyle.fieldgroups.map((x) => [x.identifier, x]),
);
const newFieldGroups = Object.fromEntries(
newStyle.fieldgroups.map((x) => [x.identifier, x]),
);
const keysToKeep = new Set(
Object.keys(this.currentPlatformLayout.layout.fieldgroups).filter(
(x) =>
oldFieldGroups[x]?.type === "placeholder" &&
newFieldGroups[x]?.type === "placeholder" &&
oldFieldGroups[x]?.content_type ===
newFieldGroups[x]?.content_type,
),
);
const keysToDefault = new Set(Object.keys(newFieldGroups)).difference(
keysToKeep,
);
const keysToRemove = new Set(
Object.keys(this.currentPlatformLayout.layout.fieldgroups),
)
.difference(keysToKeep)
.difference(keysToDefault);
for (const key of keysToRemove) {
delete this.currentPlatformLayout.layout.fieldgroups[key];
}
for (const key of keysToDefault) {
if (newFieldGroups[key].type == "placeholder") {
this.currentPlatformLayout.layout.fieldgroups[key] = {
overflow: null,
entries: JSON.parse(
JSON.stringify(newFieldGroups[key].default_entries),
),
active:
newFieldGroups[key].required ||
newFieldGroups[key].default_entries.length > 0,
};
} else {
this.currentPlatformLayout.layout.fieldgroups[key] = {
active: newFieldGroups[key].required,
};
}
}
this.currentPlatformLayout.style = style;
}
},
setSetting(identifier: string, value: string | File | null) {
this.currentPlatformLayout.layout.settings[identifier] = value;
},
},
});
}
@@ -0,0 +1,252 @@
import { i18nstringLocalize } from "./helpers.js";
import { createStore } from "./lib/store.ts";
import { toRaw, type InjectionKey } from "vue";
export type WidgetStore = ReturnType<typeof createWalletStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
export function createWalletStore(config: {
platforms: Platforms;
variables: VariableConfig;
locales: Record<string, string>;
csrfToken: string;
layoutId: string;
}) {
return createStore({
state: () => ({
...config,
walletLayout: null as WalletLayout | null,
currentPlatform: config.platforms[0].identifier,
loaded: false,
files: {} as Record<string, File>
}),
getters: {
currentPlatformStyles() {
for (const platform of this.platforms) {
if (platform.identifier === this.currentPlatform) {
return platform.styles;
}
}
throw "Unknown platform";
},
currentPlatformLayout(): PlatformLayout {
if (!this.walletLayout) {
throw "currentPlatformLayout access before store was loaded";
}
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: {}, settings: {} },
};
this.walletLayout.platform_layouts.push(newLayout);
return newLayout;
},
currentLayoutFieldContent() {
const content = {};
const group_defs =
this.currentPlatformStyles[this.currentPlatformLayout.style]
.fieldgroups;
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
content[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;
let label = i18nstringLocalize(entry.label);
if (placeholder && !label) {
label = i18nstringLocalize(placeholder.label);
}
let value = null;
if (entry.type == "custom") {
value = i18nstringLocalize(entry.content);
} else if (entry.type == "placeholder") {
value =
placeholder?.sample ||
`(unknown placeholder: ${entry.content})`;
}
content[fieldgroup.identifier].push({
entry,
label,
content: value,
});
}
}
}
for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") {
const layout_group: PlaceholderFieldGroupConfig =
this.currentPlatformLayout.layout.fieldgroups[
fieldgroup.identifier
];
if (
fieldgroup.max_entries &&
content[fieldgroup.identifier].length > fieldgroup.max_entries
) {
const overflow = content[fieldgroup.identifier].slice(
fieldgroup.max_entries,
);
content[fieldgroup.identifier] = content[
fieldgroup.identifier
].slice(0, fieldgroup.max_entries);
if (layout_group.overflow) {
content[layout_group.overflow].splice(0, 0, ...overflow);
}
}
}
}
return content;
},
currentLayoutSettings() {
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
},
},
actions: {
load() {
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
)
.then((x) => x.json())
.then((x) => {
this.walletLayout = x;
this.loaded = true;
});
},
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}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": this.csrfToken,
},
body: JSON.stringify(layoutToSave),
},
)
.then((x) => x.json())
.catch((x) => alert(x))
.then((x) => {
this.walletLayout = x;
});
},
setCurrentPlatformStyle(style: string | null) {
if (style === null) {
this.currentPlatformLayout.style = null;
this.currentPlatformLayout.layout.fieldgroups = {};
} else if (Object.keys(this.currentPlatformStyles).includes(style)) {
const oldStyle =
this.currentPlatformLayout.style !== null
? this.currentPlatformStyles[this.currentPlatformLayout.style]
: { fieldgroups: [] };
const newStyle = this.currentPlatformStyles[style];
const oldFieldGroups = Object.fromEntries(
oldStyle.fieldgroups.map((x) => [x.identifier, x]),
);
const newFieldGroups = Object.fromEntries(
newStyle.fieldgroups.map((x) => [x.identifier, x]),
);
const keysToKeep = new Set(
Object.keys(this.currentPlatformLayout.layout.fieldgroups).filter(
(x) =>
oldFieldGroups[x]?.type === "placeholder" &&
newFieldGroups[x]?.type === "placeholder" &&
oldFieldGroups[x]?.content_type ===
newFieldGroups[x]?.content_type,
),
);
const keysToDefault = new Set(Object.keys(newFieldGroups)).difference(
keysToKeep,
);
const keysToRemove = new Set(
Object.keys(this.currentPlatformLayout.layout.fieldgroups),
)
.difference(keysToKeep)
.difference(keysToDefault);
for (const key of keysToRemove) {
delete this.currentPlatformLayout.layout.fieldgroups[key];
}
for (const key of keysToDefault) {
if (newFieldGroups[key].type == "placeholder") {
this.currentPlatformLayout.layout.fieldgroups[key] = {
overflow: null,
entries: JSON.parse(
JSON.stringify(newFieldGroups[key].default_entries),
),
active:
newFieldGroups[key].required ||
newFieldGroups[key].default_entries.length > 0,
};
} else {
this.currentPlatformLayout.layout.fieldgroups[key] = {
active: newFieldGroups[key].required,
};
}
}
this.currentPlatformLayout.style = style;
}
},
setSetting(identifier: string, value: string | File | null) {
this.currentPlatformLayout.layout.settings[identifier] = value;
},
},
});
}
+9 -12
View File
@@ -384,21 +384,18 @@ class PassStyle:
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))
def extract_file_settings(self, request):
file_settings = {}
def extract_file_settings(self, request, file_settings):
res = {}
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]
if file_settings.get(setting.identifier) == "file:keep":
res[setting.identifier] = "keep"
elif data := file_settings.get(setting.identifier):
res[setting.identifier] = handle_file_upload(data, request.user, request.auth, {"image/png", "image/jpeg"})
elif setting.identifier in file_settings:
res[setting.identifier] = None
return file_settings
return res
def get_pass_fields(self, op: OrderPosition):
context = WalletPlaceholderContext(