mirror of
https://github.com/pretix/pretix.git
synced 2026-08-13 11:17:01 +00:00
WIP
mvp (file)settings
This commit is contained in:
@@ -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
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -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>
|
||||
|
||||
+1
-1
@@ -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;
|
||||
|
||||
+7
@@ -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>
|
||||
|
||||
-58
@@ -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>
|
||||
+1
@@ -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;
|
||||
|
||||
+4
-3
@@ -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>
|
||||
|
||||
+42
@@ -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;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user