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.api.views.order import OrderPositionFilter
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition, Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken, Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
) )
from pretix.base.models.orders import PrintLog 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.services.media import perform_media_exchange
from pretix.base.signals import checkin_annulled from pretix.base.signals import checkin_annulled
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.api.helpers import handle_file_upload
with scopes_disabled(): with scopes_disabled():
class CheckinListFilter(FilterSet): 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): 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: try:
if q.type == Question.TYPE_FILE: if q.type == Question.TYPE_FILE:
if answers_data[str(q.pk)]: 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: else:
given_answers[q] = None given_answers[q] = None
else: 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 django.db import transaction
from .styles import AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS from .styles import AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS
from .models import WalletLayout, WalletPlatformLayout from .models import WalletLayout, WalletPlatformLayout
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.fields import UploadedFileField
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .views import get_editor_placeholders
from rest_framework import serializers from rest_framework import serializers
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer): 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) style = serializers.CharField(allow_null=True, required=False)
class Meta: class Meta:
@@ -23,9 +24,9 @@ class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
return value return value
def validate(self, data): def validate(self, data):
platform = data.get('platform') platform = data.get("platform")
style = data.get('style') style = data.get("style")
layout = data.get('layout') layout = data.get("layout")
if platform and style and layout: if platform and style and layout:
platform_styles = AVAILABLE_STYLES_DICT[platform] platform_styles = AVAILABLE_STYLES_DICT[platform]
@@ -33,10 +34,24 @@ class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
raise ValidationError(_("Invalid style")) raise ValidationError(_("Invalid style"))
style = platform_styles[data["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() style.validate()
data['file_settings'] = style.extract_file_settings(self.context['request'])
return data 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): class WalletLayoutSerializer(I18nAwareModelSerializer):
platform_layouts = WalletPlatformLayoutSerializer(many=True) platform_layouts = WalletPlatformLayoutSerializer(many=True)
@@ -53,11 +68,28 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
super().save(*args, **kwargs, event=self.context["event"]) super().save(*args, **kwargs, event=self.context["event"])
def update(self, instance, validated_data): 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: for layout in platform_layouts:
if layout['style']: if layout["style"]:
instance.platform_layouts.update_or_create(platform=layout['platform'], defaults=layout) # TODO: better handling here
instance.platform_layouts.exclude(platform__in={layout['platform'] for layout in platform_layouts if layout['style'] is not None}).delete() 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) 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) style = get_style(self.platform, self.style)
if 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: else:
raise RuntimeError(f"Style {self.platform}.{self.style} not found") 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: if self.item.event != self.layout.event:
raise ValidationError("cannot bind layout to item of different 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 # smth like this for apple, lets see what the best architecture for google will be
# class AppleWalletPass(models.Model): # class AppleWalletPass(models.Model):
# platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT) # platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT)
@@ -70,7 +70,6 @@ const preview_layout = computed(() => {
.tab-pane.active.row .tab-pane.active.row
.col-md-6.col-lg-8 .col-md-6.col-lg-8
Select.form-group(label="Style" :modelValue="store.currentPlatformLayout.style" @update:modelValue="store.setCurrentPlatformStyle" :choices="platformChoices") 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]") StyleSettings(v-if="store.currentPlatformLayout.style" v-model="store.currentPlatformLayout.layout" :style="store.currentPlatformStyles[store.currentPlatformLayout.style]")
.col-md-6.col-lg-4 .col-md-6.col-lg-4
.panel.panel-default .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"> <script setup lang="ts">
import { useId } from 'vue' import { useId } from 'vue'
const gettext = (window as any).gettext;
defineOptions({ defineOptions({
inheritAttrs: false inheritAttrs: false
}) })
const props = defineProps<{ const {label, errors, type = "text"} = defineProps<{
label?: string, label?: I18nString,
errors?: string[], errors?: string[],
type?: string
help_text?: string,
}>() }>()
const modelValue = defineModel<string|null>(); const modelValue = defineModel<string|null>();
const id = useId() const id = useId()
</script> </script>
<template lang="pug"> <template lang="pug">
label.control-label(:for="id", v-if="props.label") {{ props.label }} label.control-label(:for="id", v-if="label") {{ label }}
input.form-control(:id="id" v-model="modelValue" v-bind="$attrs") br(v-if="!$attrs.required")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }} 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> </template>
@@ -6,7 +6,7 @@ import { i18nstringLocalize } from "../../helpers";
const store = inject(StoreKey)!; const store = inject(StoreKey)!;
const props = defineProps<{ const props = defineProps<{
label: I18nString; label?: I18nString;
content: I18nString; content: I18nString;
content_type: FieldContentType; content_type: FieldContentType;
display: FieldGroupDisplay; display: FieldGroupDisplay;
@@ -17,9 +17,16 @@ const style_def = computed(() => {
].fieldgroups.map((x) => [x.identifier, x]), ].fieldgroups.map((x) => [x.identifier, x]),
)[props.config.fieldgroup]; )[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> </script>
<template lang="pug"> <template lang="pug">
div(@click="focusGroup")
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def") 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") PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
</template> </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 { .fieldgroup-item-image {
max-height: 3lh; max-height: 3lh;
object-fit: contain;
} }
.fieldgroup-item-qrcode { .fieldgroup-item-qrcode {
font-weight: bold; font-weight: bold;
@@ -1,7 +1,7 @@
<script setup lang="ts"> <script setup lang="ts">
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
import FieldgroupPreview from './fieldgroup-preview.vue'; import FieldgroupPreview from './fieldgroup-preview.vue';
import FixedPreview from './fixed-preview.vue'; import SettingPreview from './setting-preview.vue';
const props = defineProps<{ const props = defineProps<{
config: PreviewRow; 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") 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" ) RowPreview(v-for="child of config.children" :config="child" )
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config") 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> </template>
<style lang="css" scoped> <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 { computed, inject, watchEffect } from "vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue"; import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue"; import PredefinedFieldSettings from "./predefined-field-settings.vue";
import SettingsField from "./settings-field.vue";
const gettext = (window as any).gettext; const gettext = (window as any).gettext;
const props = defineProps<{ const props = defineProps<{
@@ -13,9 +13,11 @@ const layout = defineModel<LayoutData>();
</script> </script>
<template lang="pug"> <template lang="pug">
h2.h3 {{ gettext("Settings") }}
SettingsField(v-for="field of style.settings" :field="field" :key="field.identifier")
h2.h3 {{ gettext("Field Groups") }} h2.h3 {{ gettext("Field Groups") }}
template(v-if="props.style && layout.fieldgroups" div(v-if="props.style && layout.fieldgroups"
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups") v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
PlaceholderFieldSettings( PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'" v-if="fieldgroup.type == 'placeholder'"
v-model="layout.fieldgroups[fieldgroup.identifier]" v-model="layout.fieldgroups[fieldgroup.identifier]"
@@ -44,10 +44,17 @@ type CustomFieldEntry = {
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry; type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
type Setting = {
identifier: string;
label: I18nString;
type: "text" | "image";
required: boolean;
};
type Style = { type Style = {
identifier: string; identifier: string;
name: string; name: string;
fieldgroups: FieldGroupDefinition[]; fieldgroups: FieldGroupDefinition[];
settings: Setting[]
}; };
type Variable = { type Variable = {
@@ -82,7 +89,8 @@ type FieldGroupConfig =
| PredefinedFieldGroupConfig; | PredefinedFieldGroupConfig;
type LayoutData = { type LayoutData = {
fieldgroups: Record<string, FieldGroupConfig>; fieldgroups?: Record<string, FieldGroupConfig>;
settings?: Record<string, any>
}; };
type PlatformLayout = { type PlatformLayout = {
@@ -107,18 +115,20 @@ type PreviewLayout = Array<PreviewRow>;
type PreviewRow = type PreviewRow =
| { children: Array<PreviewRow>; direction?: "row" | "column"; display?: Array<string>;} | { children: Array<PreviewRow>; direction?: "row" | "column"; display?: Array<string>;}
| PreviewFieldgroup | PreviewFieldgroup
| FixedPreview; | FixedPreview
| SettingPreview;
type PreviewProps = { type PreviewProps = {
relSize?: number; relSize?: number;
direction?: "row" | "column"; direction?: "row" | "column";
display?: Array<string>; display?: Array<string>;
} }
type SettingPreview = {
setting: string;
} & PreviewProps
type PreviewFieldgroup = PredefinedFieldgroupPreview | PlaceholderFieldGroupPreview; type PreviewFieldgroup = PredefinedFieldgroupPreview | PlaceholderFieldGroupPreview;
type FixedPreview = { type FixedPreview = {value: I18nString; label?: I18nString; } & PreviewProps
value: I18nString;
} & PreviewProps
type PlaceholderFieldGroupPreview = { type PlaceholderFieldGroupPreview = {
fieldgroup: string; fieldgroup: string;
@@ -1,6 +1,6 @@
import { i18nstringLocalize } from "./helpers.js"; import { i18nstringLocalize } from "./helpers.js";
import { createStore } from "./lib/store.ts"; 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 type WidgetStore = ReturnType<typeof createWalletStore>;
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore"); export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
@@ -11,7 +11,6 @@ export function createWalletStore(config: {
locales: Record<string, string>; locales: Record<string, string>;
csrfToken: string; csrfToken: string;
layoutId: string; layoutId: string;
// platform_layouts: Record<string, PlatformLayout>;
}) { }) {
return createStore({ return createStore({
state: () => ({ state: () => ({
@@ -19,6 +18,7 @@ export function createWalletStore(config: {
walletLayout: null as WalletLayout | null, walletLayout: null as WalletLayout | null,
currentPlatform: config.platforms[0].identifier, currentPlatform: config.platforms[0].identifier,
loaded: false, loaded: false,
files: {} as Record<string, File>
}), }),
getters: { getters: {
currentPlatformStyles() { currentPlatformStyles() {
@@ -35,13 +35,19 @@ export function createWalletStore(config: {
} }
for (const layout of this.walletLayout.platform_layouts) { for (const layout of this.walletLayout.platform_layouts) {
if (layout.platform === this.currentPlatform) { if (layout.platform === this.currentPlatform) {
if (!("fieldgroups" in layout.layout)) {
layout.layout.fieldgroups = {};
}
if (!("settings" in layout.layout)) {
layout.layout.settings = {};
}
return layout; return layout;
} }
} }
const newLayout = { const newLayout = {
platform: this.currentPlatform, platform: this.currentPlatform,
style: null, style: null,
layout: { fieldgroups: {} }, layout: { fieldgroups: {}, settings: {} },
}; };
this.walletLayout.platform_layouts.push(newLayout); this.walletLayout.platform_layouts.push(newLayout);
return newLayout; return newLayout;
@@ -55,13 +61,14 @@ export function createWalletStore(config: {
for (const fieldgroup of group_defs) { for (const fieldgroup of group_defs) {
if (fieldgroup.type == "placeholder") { if (fieldgroup.type == "placeholder") {
content[fieldgroup.identifier] = []; content[fieldgroup.identifier] = [];
const layout_group: PlaceholderFieldGroupConfig = const layout_group = this.currentPlatformLayout.layout.fieldgroups[
this.currentPlatformLayout.layout.fieldgroups[ fieldgroup.identifier
fieldgroup.identifier ] as any as PlaceholderFieldGroupConfig;
];
for (const entry of layout_group.entries) { for (const entry of layout_group.entries) {
const placeholder = 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); let label = i18nstringLocalize(entry.label);
if (placeholder && !label) { if (placeholder && !label) {
@@ -72,7 +79,9 @@ export function createWalletStore(config: {
if (entry.type == "custom") { if (entry.type == "custom") {
value = i18nstringLocalize(entry.content); value = i18nstringLocalize(entry.content);
} else if (entry.type == "placeholder") { } else if (entry.type == "placeholder") {
value = placeholder?.sample || `(unknown placeholder: ${entry.content})`; value =
placeholder?.sample ||
`(unknown placeholder: ${entry.content})`;
} }
content[fieldgroup.identifier].push({ content[fieldgroup.identifier].push({
entry, entry,
@@ -107,6 +116,9 @@ export function createWalletStore(config: {
return content; return content;
}, },
currentLayoutSettings() {
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
},
}, },
actions: { actions: {
load() { load() {
@@ -120,7 +132,40 @@ export function createWalletStore(config: {
this.loaded = true; 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 // TODO: error handling / proper api client
fetch( fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`, `/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
@@ -130,7 +175,7 @@ export function createWalletStore(config: {
"content-type": "application/json", "content-type": "application/json",
"X-CSRFToken": this.csrfToken, "X-CSRFToken": this.csrfToken,
}, },
body: JSON.stringify(this.walletLayout), body: JSON.stringify(layoutToSave),
}, },
) )
.then((x) => x.json()) .then((x) => x.json())
@@ -185,29 +230,22 @@ export function createWalletStore(config: {
entries: JSON.parse( entries: JSON.parse(
JSON.stringify(newFieldGroups[key].default_entries), 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 { } else {
this.currentPlatformLayout.layout.fieldgroups[key] = { this.currentPlatformLayout.layout.fieldgroups[key] = {
active: newFieldGroups[key].required active: newFieldGroups[key].required,
}; };
} }
} }
this.currentPlatformLayout.style = style; this.currentPlatformLayout.style = style;
} }
// else if (this.currentPlatformLayout.style === null && Object.keys(this.currentPlatformStyles).includes(style)) { },
// this.currentPlatformLayout.style = style; setSetting(identifier: string, value: string | File | null) {
// this.currentPlatformLayout.layout.fieldgroups = {} this.currentPlatformLayout.layout.settings[identifier] = value;
// } 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) { }
}, },
}, },
}); });
+33 -29
View File
@@ -1,3 +1,4 @@
from collections import OrderedDict
from typing import Any from typing import Any
from .base import ( from .base import (
@@ -10,8 +11,9 @@ from .base import (
WalletPlatform, WalletPlatform,
PassStyle, PassStyle,
PlaceholderFieldEntry, PlaceholderFieldEntry,
SettingsField,
) )
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _, gettext_lazy, override
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
import io import io
import hashlib import hashlib
@@ -23,6 +25,7 @@ import json
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from pretix.base.models import OrderPosition from pretix.base.models import OrderPosition
from django.utils.encoding import force_bytes from django.utils.encoding import force_bytes
from django import forms
class ApplePlatform(WalletPlatform): class ApplePlatform(WalletPlatform):
@@ -38,6 +41,13 @@ class FormattedLazyI18nString:
def localize(self, language): def localize(self, language):
return self.base_str.localize(language).format(**self.format_args) 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: class StringResource:
entries: dict[str, LazyI18nString | FormattedLazyI18nString] entries: dict[str, LazyI18nString | FormattedLazyI18nString]
@@ -131,6 +141,25 @@ class SignedZipFile:
class AppleWalletStyle(PassStyle): 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): def pass_content(self, fields, strings):
raise NotImplementedError() raise NotImplementedError()
@@ -180,6 +209,7 @@ class AppleWalletStyle(PassStyle):
pass_json = self.generate_pass_json(fields, op, strings) pass_json = self.generate_pass_json(fields, op, strings)
print(pass_json) print(pass_json)
breakpoint()
if fields["logo"]: if fields["logo"]:
logo = fields["logo"][0]["value"] logo = fields["logo"][0]["value"]
else: else:
@@ -204,32 +234,6 @@ class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1" identifier = "event_1"
name = _("Event Ticket Layout 1") name = _("Event Ticket Layout 1")
fieldgroups = [ 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( TextFieldGroup(
identifier="logo_text", identifier="logo_text",
name=_("Logo text"), name=_("Logo text"),
@@ -248,7 +252,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}), label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item", content="item",
) )
], # TODO: support Lazyi18nproxy here ], # TODO: support Lazyi18nproxy here by using lazyi18nstring_from_gettext
description=_("These fields appear prominently featured on the pass."), description=_("These fields appear prominently featured on the pass."),
required=True, required=True,
context_args={"event", "order", "order_position"}, context_args={"event", "order", "order_position"},
@@ -293,7 +297,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
[ [
{ {
"children": [ "children": [
{"fieldgroup": "logo", "relSize": 1}, {"setting": "logo"},
{ {
"fieldgroup": "logo_text", "fieldgroup": "logo_text",
"relSize": 3, "relSize": 3,
+75 -5
View File
@@ -1,11 +1,13 @@
import enum import enum
from typing import TypedDict from typing import Literal, OrderedDict, TypedDict
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
import jsonschema import jsonschema
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from pretix.base.models import OrderPosition from pretix.base.models import OrderPosition
from ..placeholders import WalletPlaceholderContext, get_wallet_placeholders 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: class WalletPlatform:
identifier: str identifier: str
@@ -253,6 +255,37 @@ class ImageFieldGroup(PlaceholderFieldGroup):
super().__init__(content_type=self.content_type, display=self.display, **kwargs) 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: class PassStyle:
identifier: str # unique within platform identifier: str # unique within platform
name: str name: str
@@ -261,6 +294,10 @@ class PassStyle:
# we evaluate the fields in this order, so they overspill in this order as well # we evaluate the fields in this order, so they overspill in this order as well
fieldgroups: list[FieldGroup] fieldgroups: list[FieldGroup]
@property
def settings(self) -> list[SettingsField]:
return []
@property @property
def preview_layout(self) -> list | None: def preview_layout(self) -> list | None:
return None return None
@@ -271,10 +308,12 @@ class PassStyle:
"name": self.name, "name": self.name,
"fieldgroups": [x.asdict() for x in self.fieldgroups], "fieldgroups": [x.asdict() for x in self.fieldgroups],
"preview_layout": self.preview_layout, "preview_layout": self.preview_layout,
"settings": [x.asdict() for x in self.settings],
} }
def layout_schema(self): def layout_schema(self):
context = LayoutContext(placeholders=self.placeholders) context = LayoutContext(placeholders=self.placeholders)
print(f"schema {self.settings=}")
schema = { schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema", "$schema": "https://json-schema.org/draft/2020-12/schema",
# TODO: $id # TODO: $id
@@ -293,6 +332,16 @@ class PassStyle:
"required": [ "required": [
group.identifier for group in self.fieldgroups if group.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": { "$defs": {
@@ -305,7 +354,11 @@ class PassStyle:
}, },
} }
if any(group.required for group in self.fieldgroups): 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 return schema
@@ -318,10 +371,11 @@ class PassStyle:
return None, None 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.event = event
self.layout = layout self.layout = layout
self.placeholders = get_wallet_placeholders(self.event) self.placeholders = get_wallet_placeholders(self.event)
self.file_settings = file_settings
def validate(self): def validate(self):
schema = self.layout_schema() schema = self.layout_schema()
@@ -330,6 +384,22 @@ class PassStyle:
except jsonschema.ValidationError as e: except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(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): def get_pass_fields(self, op: OrderPosition):
context = WalletPlaceholderContext( context = WalletPlaceholderContext(
event=self.event, order=op.order, order_position=op event=self.event, order=op.order, order_position=op
@@ -380,7 +450,7 @@ class PassStyle:
return fields return fields
def group_is_active(self, identifier: str): 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): def generate(self, op: OrderPosition):
raise NotImplementedError() raise NotImplementedError()
+12 -4
View File
@@ -10,6 +10,7 @@ from django.urls import reverse
from django.http import HttpResponse, HttpResponseRedirect from django.http import HttpResponse, HttpResponseRedirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, ListView, DeleteView, View 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.i18n import language
from pretix.base.pdf import get_images, get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.services.tickets import get_preview_position 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.utils.functional import cached_property
from django.templatetags.static import static from django.templatetags.static import static
from .placeholders import get_wallet_placeholders, WalletPlaceholderContext from .placeholders import get_wallet_placeholders, WalletPlaceholderContext
from pretix.base.middleware import add_to_response_csp
def get_editor_placeholders(event): def get_editor_placeholders(event):
with ( with (
@@ -76,7 +77,7 @@ class LayoutEditorView(LayoutDetailView):
"identifier": platform.identifier, "identifier": platform.identifier,
"name": platform.name, "name": platform.name,
"styles": { "styles": {
style.identifier: style(self.request.event, None).asdict() style.identifier: style(self.request.event).asdict()
for style in AVAILABLE_STYLES.get(platform.identifier) for style in AVAILABLE_STYLES.get(platform.identifier)
}, },
} }
@@ -89,7 +90,12 @@ class LayoutEditorView(LayoutDetailView):
} }
return context 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 WalletLayoutCreateForm(forms.ModelForm):
class Meta: class Meta:
@@ -171,7 +177,9 @@ class LayoutPreviewView(EventPermissionRequiredMixin, View):
language(request.event.settings.locale, request.event.settings.region), language(request.event.settings.locale, request.event.settings.region),
): ):
p = get_preview_position(request.event) 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() layout.validate()
fname, mimet, data = layout.generate(p) fname, mimet, data = layout.generate(p)