WIP: i18n editor, start apple wallet generation

This commit is contained in:
Kara Engelhardt
2026-04-15 18:59:32 +02:00
parent c48d30919f
commit 9064069cf3
10 changed files with 689 additions and 62 deletions

View File

@@ -49,7 +49,7 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
style = platform_styles[data["style"]]
layout = PassLayout(style=style, layout=data["layout"])
context = {"placeholders": {k: list(v.keys()) for k,v in get_layout_variables(self.context['event']).items()}}
context = {"placeholders": {k: {"content": v['content']} for k,v in get_layout_variables(self.context['event']).items()}}
layout.validate(context=context)
return data

View File

@@ -1,15 +1,15 @@
<script setup lang="ts">
import { useId, watchEffect } from 'vue'
import { watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string,
errors?: string[],
locales: Record<string, string>
}>()
}>();
const modelValue = defineModel<Record<string, string> | string>();
watchEffect(() => {
if (typeof modelValue.value === "string") {
@@ -17,12 +17,9 @@ watchEffect(() => {
modelValue.value = Object.fromEntries(Object.keys(props.locales).map((x): [string, string] => [x, oldVal]))
}
})
const id = useId()
</script>
<template lang="pug">
label.control-label(:for="id", v-if="props.label") {{ props.label }}
.i18n-form-group
input.form-control(v-for="(human_readable, locale) in locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
input.form-control(v-for="(human_readable, locale) in locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -6,7 +6,7 @@ defineOptions({
})
const props = defineProps<{
label: string
label?: string
choices: Array<[string, string]>
errors?: string[]
}>()
@@ -24,7 +24,7 @@ watchEffect(() => {
<template lang="pug">
template(v-if="choices.length >= 1")
label.control-label(:for="id") {{ props.label }}
label.control-label(v-if="props.label" :for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}

View File

@@ -8,7 +8,7 @@ import TextContent from "./text-content.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: FieldGroupDefinition;
fieldgroup: PlaceholderFieldGroupDefinition;
overflows: FieldGroupDefinition[];
variables: Variables;
locales: Record<string, string>;
@@ -48,26 +48,33 @@ watchEffect(() => {
.form-group()
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
h4 {{ gettext("Content") }}
.row.form-group(v-for="n in fieldConfig.entries.length")
.col-md-5(v-if="fieldgroup.labels")
I18nInput(:label="gettext('Label')" v-model="fieldConfig.entries[n-1].label" :locales="locales")
div(:class="'col-md-' + (fieldgroup.labels ? '6' : '11')")
TextContent(v-if='fieldgroup.content_type == "text"'
v-model="fieldConfig.entries[n-1]"
:variables="props.variables")
Select(:label="gettext('Content')"
v-else-if='fieldgroup.content_type == "image"'
v-model="fieldConfig.entries[n-1].content"
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
)
.col-md-1
label.control-label &nbsp;
span.sr-only {{ gettext('Delete')}}
button.btn.btn-danger(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
i.fa.fa-trash
span.sr-only {{ gettext('Delete')}}
table.table.table-hover
thead
tr
th.col-md-5(v-if="fieldgroup.labels") {{ gettext('Label') }}
th(:class="'col-md-' + (fieldgroup.labels ? '6' : '11')") {{ gettext('Content') }}
th.col-xs-1
tbody
tr(v-for="n,i in fieldConfig.entries.length" :key="i")
td(v-if="fieldgroup.labels")
.i18n-form-group
I18nInput(v-model="fieldConfig.entries[n-1].label" :locales="locales")
td
TextContent(v-if='fieldgroup.content_type == "text"'
v-model="fieldConfig.entries[n-1]"
:variables="props.variables"
:locales="locales")
Select(v-else-if='fieldgroup.content_type == "image"'
v-model="fieldConfig.entries[n-1].content"
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
)
td.text-right
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
i.fa.fa-trash
span.sr-only {{ gettext('Delete')}}
button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus
span.sr-only {{ gettext("Add field") }}
| {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</template>
</template>

View File

@@ -1,12 +1,13 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import Select from './input/select.vue'
import Input from './input/input.vue'
import I18nInput from './input/i18ninput.vue'
const gettext = (window as any).gettext
const props = defineProps<{
variables: Variables
locales: Record<string, string>;
}>()
const entry = defineModel<FieldEntry>({ required: true })
@@ -29,7 +30,7 @@ const selection = computed({
set(newValue) {
if (newValue == "other") {
entry.value.type = "text"
entry.value.content = "";
entry.value.content = {};
} else {
entry.value.type = "placeholder"
entry.value.content = newValue
@@ -55,9 +56,10 @@ const textContent = computed({
</script>
<template lang="pug">
Select(:label="gettext('Content')"
v-model="selection"
:choices="selectChoices"
)
Input(v-model="textContent" v-if="selection === 'other'")
.i18n-form-group
Select(
v-model="selection"
:choices="selectChoices"
)
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="locales")
</template>

View File

@@ -5,7 +5,7 @@ type BaseFieldGroupDefinition = {
required: boolean;
}
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroup;
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroupDefinition;
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: 'placeholder';
@@ -24,13 +24,19 @@ type I18nString = string | Record<string, string>
type FieldContentType = 'text' | 'image';
type FieldEntry = {
type: 'placeholder' | FieldContentType;
type PlaceholderFieldEntry = {
type: 'placeholder';
label?: I18nString;
content?: string;
}
type ContentFieldEntry = {
type: FieldContentType;
label?: I18nString;
content?: I18nString;
}
type FieldEntry = PlaceholderFieldEntry | ContentFieldEntry;
type Style = {
identifier: string;

View File

@@ -1,25 +1,132 @@
from .base import (
FieldEntry,
FieldEntryContentType,
FieldEntryType,
FieldContentType,
ImageFieldGroup,
PlaceholderFieldGroup,
TextFieldGroup,
WalletPlatform,
PassStyle,
PlaceholderFieldEntry,
CustomFieldEntry,
)
from django.utils.translation import gettext as _
from i18nfield.strings import LazyI18nString
import io
import hashlib
import zipfile
import cryptography
import cryptography.hazmat.primitives.serialization.pkcs7
# from cryptography import x509
# from cryptography.hazmat.primitives import hashes, serialization
# from cryptography.hazmat.primitives.serialization import pkcs7
import json
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class StringResource:
# mapping string in default event locale -> LazyI18nString
entries: dict[str, LazyI18nString]
locales: set[str]
def __init__(self, locales):
self.entries = {}
self.locales = set(locales)
def add_entry(self, key: str, value: LazyI18nString): # TODO: replace LazyI18nString with dict or handle strings where data == ""
if key in self.entries:
raise ValueError(f"{key} already exists in this StringResource")
self.entries[key] = value
if isinstance(value.data, dict):
self.locales |= value.data.keys()
def escape(self, string):
return string.translate(str.maketrans({"\"": "\\\"", "\r": "\\r", "\n": "\\n", "\\": "\\\\"}))
def generate_resource(self, language):
output = ""
for key, entry in self.entries.items():
output += f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
return output.strip()
def generate(self):
return {language: self.generate_resource(language) for language in self.locales}
class SignedZipFile:
""" Generates a zip-file with manifest and signature as apple expects a pkpass file to be """
def __init__(self, ca_certificate, certificate, key, password):
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(ca_certificate)
self.certificate = cryptography.x509.load_pem_x509_certificate(certificate)
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(key, password)
self.password = password
self.file = io.BytesIO()
self.zip_file = zipfile.ZipFile(self.file, "w")
self.manifest = {}
def sign(self, data: bytes):
return (
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
.set_data(data)
.add_signer(
self.certificate,
self.key,
cryptography.hazmat.primitives.hashes.SHA256(),
)
.add_certificate(self.ca_certificate)
.sign(
cryptography.hazmat.primitives.serialization.Encoding.DER,
[
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
],
)
)
def finish(self):
manifest = json.dumps(self.manifest).encode()
signature = self.sign(manifest)
self.add_file("manifest.json", manifest)
self.add_file("signature", signature)
self.zip_file.close()
return self.file.getvalue()
def add_file(self, filename: str, content: str | bytes):
if isinstance(content, str):
content = content.encode()
with self.zip_file.open(filename, "w") as f:
f.write(content)
self.manifest[filename] = hashlib.sha1(content).hexdigest()
class AppleWalletStyle(PassStyle):
platform = ApplePlatform
def generate_pass_json(self, layout, context):
pass_json = {}
return pass_json
def generate(self, layout, context):
for key in ["certificate", "key", "wwdr_certificate", "password"]:
if key not in context:
raise ValueError(f"{key} missing from context")
pkpass = SignedZipFile(
context["certificate"],
context["key"],
context["wwdr_certificate"],
context["password"],
)
pass_json = self.generate_pass_json()
pkpass.add_file("pass.json", json.dumps(pass_json))
return pkpass.finish()
class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout 1")
@@ -31,10 +138,8 @@ class AppleWalletEventTicket(AppleWalletStyle):
max_entries=1,
labels=False,
default_entries=[
FieldEntry(
type=FieldEntryContentType.IMAGE,
label=LazyI18nString("logo"),
content="event:image",
PlaceholderFieldEntry(
content="poweredby",
)
],
),
@@ -44,13 +149,12 @@ class AppleWalletEventTicket(AppleWalletStyle):
min_entries=1,
max_entries=1,
default_entries=[
FieldEntry(
type=FieldEntryContentType.PLACEHOLDER,
PlaceholderFieldEntry(
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item",
)
], # TODO: support Lazyi18nproxy here
description=_("These fields appear prominently featured on the pass.")
description=_("These fields appear prominently featured on the pass."),
),
TextFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4
@@ -62,5 +166,3 @@ class AppleWalletEventTicket(AppleWalletStyle):
TextFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"

View File

@@ -1,6 +1,7 @@
import enum
from i18nfield.strings import LazyI18nString
import jsonschema
from django.core.exceptions import ValidationError
class WalletPlatform:
identifier: str
@@ -47,19 +48,19 @@ class FieldContentType(enum.Enum):
TEXT = "text"
class FieldEntryContentType(enum.Enum):
class FieldEntryType(enum.Enum):
IMAGE = "image"
TEXT = "text"
PLACEHOLDER = "placeholder"
class FieldEntry:
type: FieldEntryContentType
class FieldEntry[T]:
type: FieldEntryType
label: LazyI18nString | None
content: str
content: T
def __init__(
self, type: FieldEntryContentType, label: LazyI18nString | None, content: str
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
):
self.type = type
self.label = label
@@ -68,6 +69,27 @@ class FieldEntry:
def asdict(self) -> dict:
return {"type": self.type.value, "content": self.content, "label": self.label.data if self.label else None}
class PlaceholderFieldEntry(FieldEntry[str]):
type = FieldEntryType.PLACEHOLDER
label: LazyI18nString | None
content: str
def __init__(
self, content: str, label: LazyI18nString | None = None
):
self.label = label
self.content = content
class CustomFieldEntry(FieldEntry[LazyI18nString]):
type: FieldEntryType
label: LazyI18nString | None
content: LazyI18nString
def asdict(self) -> dict:
return {"type": self.type.value, "content": self.content.data, "label": self.label.data if self.label else None}
class PredefinedFieldGroup(FieldGroup):
type = FieldGroupType.PREDEFINED
@@ -126,13 +148,13 @@ class PlaceholderFieldGroup(FieldGroup):
remaining_fields: list["FieldGroup"],
context: dict,
):
placeholders = context.get("placeholders", {}).get(self.content_type.value, [])
placeholders = list(context.get("placeholders", {}).get(self.content_type.value, {}).keys())
return {
"type": "object",
"properties": {
"entries": self.entries_schema(placeholders=placeholders),
"overflow": {
"oneOf": [
"anyOf": [
{"type": "null"},
{
"type": "string",
@@ -158,7 +180,7 @@ class PlaceholderFieldGroup(FieldGroup):
"type": "array",
"items": {
"type": "object",
"oneOf": [
"anyOf": [
{
"properties": {
**baseprops,
@@ -170,7 +192,7 @@ class PlaceholderFieldGroup(FieldGroup):
"properties": {
**baseprops,
"type": {"const": self.content_type.value},
"content": {"type": "string"},
"content": {"$ref": "#/$defs/I18nString"},
}
},
],
@@ -252,6 +274,8 @@ class PassStyle:
return schema
def generate(self, layout, context):
raise NotImplementedError()
class PassLayout:
style: PassStyle
@@ -267,3 +291,7 @@ class PassLayout:
jsonschema.validate(self.layout, schema)
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))
def generate(self, context):
self.validate(context)
return self.style.generate(self.layout, context)

View File

@@ -26,6 +26,9 @@ def get_layout_variables(event):
| {"poweredby": {"label": _("pretix-Logo")}}, # TODO: image upload
}
def get_editor_variables(event):
return {t: {vid: {"label": v.get("label"), "editor_sample": v.get("editor_sample")} for vid,v in vs.items()} for t,vs in get_layout_variables(event).items()}
# TODO: should this even be a list view?
class LayoutListView(EventPermissionRequiredMixin, ListView):
@@ -61,7 +64,7 @@ class LayoutEditorView(DetailView):
context["styles"] = {
style.identifier: style.asdict() for style in self.get_platform_styles()
}
context["variables"] = get_layout_variables(self.request.event)
context["variables"] = get_editor_variables(self.request.event)
context['locales'] = {l: dict(settings.LANGUAGES).get(l, l) for l in self.request.event.settings.get('locales')}
return context