WIP: i18nfields, refactoring, jsonschema-validatoin

This commit is contained in:
Kara Engelhardt
2026-04-14 21:12:30 +02:00
parent 30b64546a7
commit c48d30919f
18 changed files with 691 additions and 483 deletions

View File

@@ -1,65 +1,83 @@
from rest_framework import viewsets
from django.db import transaction
from .styles import PassLayout, get_platform_styles, get_platforms
from .styles import PassLayout, AVAILABLE_STYLES_DICT
from .models import WalletLayout
from pretix.api.serializers.i18n import I18nAwareModelSerializer
import django_filters.rest_framework
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from .views import get_layout_variables
class WalletLayoutSerializer(I18nAwareModelSerializer):
class Meta:
model = WalletLayout
fields = ("event","platform","name","style","layout")
read_only_fields = ("event", "platform")
fields = ("id", "platform", "name", "style", "layout")
read_only_fields = ("id",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance:
self.fields['platform'].read_only = True
def save(self, *args, **kwargs):
super().save(*args, **kwargs, event=self.context["event"])
def validate_platform(self, value):
if self.instance and value != self.instance.platform:
raise ValidationError(_("Platform cannot be changed"))
if value not in AVAILABLE_STYLES_DICT:
raise ValidationError(_("Invalid platform"))
return value
def validate_layout(self, value):
if not isinstance(value, dict):
raise ValidationError(_("Layout must be a dict"))
return value
def validate_platform(self, value):
if value not in get_platforms():
raise ValidationError(_("Invalid Platform"))
return value
def validate(self, data):
if "style" in data and "layout" in data and "platform" in data:
platform_styles = get_platform_styles(data['platform'])
if data['style'] not in platform_styles:
if self.instance:
platform = self.instance.platform
else:
platform = data.get('platform', None)
if "style" in data and "layout" in data and platform:
platform_styles = AVAILABLE_STYLES_DICT[platform]
if data["style"] not in platform_styles:
raise ValidationError(_("Invalid style"))
style = get_platform_styles(data['platform'])[data['style']]
style = platform_styles[data["style"]]
layout = PassLayout(
style=style, layout=data["layout"]
)
breakpoint()
layout.validate(data['event'])
layout = PassLayout(style=style, layout=data["layout"])
context = {"placeholders": {k: list(v.keys()) for k,v in get_layout_variables(self.context['event']).items()}}
layout.validate(context=context)
return data
class WalletLayoutViewSet(viewsets.ModelViewSet):
model = WalletLayout
queryset = WalletLayout.objects.none()
serializer_class = WalletLayoutSerializer
filter_backends = (django_filters.rest_framework.DjangoFilterBackend,)
filterset_fields = ['platform']
filterset_fields = ["platform"]
permission = "event.settings.general:write"
def get_queryset(self):
return self.request.event.wallet_layouts.all()
def get_serializer(self, *args, **kwargs):
return super().get_serializer(*args, **kwargs)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx["event"] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
action='pretix.plugins.wallet.layout.changed',
action="pretix.plugins.wallet.layout.changed",
user=self.request.user,
auth=self.request.auth,
data=self.request.data,

View File

@@ -23,6 +23,7 @@ from django.db import models
from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel
from django_scopes import ScopedManager
class WalletLayout(LoggedModel):
@@ -37,11 +38,26 @@ class WalletLayout(LoggedModel):
)
platform = models.CharField(max_length=10)
style = models.CharField(max_length=255)
layout = models.JSONField(default={})
layout = models.JSONField(default=dict)
objects = ScopedManager(organizer='event__organizer')
class Meta:
ordering = ("name",)
def __str__(self):
return self.name
# TODO:ScopedManager
class WalletLayoutItem(models.Model):
item = models.ForeignKey('pretixbase.Item', null=True, blank=True, related_name='walletlayout_assignments',
on_delete=models.CASCADE)
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
sales_channel = models.ForeignKey(
"pretixbase.SalesChannel",
on_delete=models.CASCADE,
)
class Meta:
unique_together = (('item', 'layout', 'sales_channel'),)
ordering = ("id",)

View File

@@ -10,52 +10,55 @@ const isLoading = ref<boolean>(true);
const wallet_layout = ref<Layout | null>(null);
const STYLES: Styles = JSON.parse(
document.querySelector("#styles")?.textContent ?? "{}",
document.querySelector("#styles")?.textContent ?? "{}",
);
const VARIABLES: VariableConfig = JSON.parse(
document.querySelector("#variables")?.textContent ?? "{}",
document.querySelector("#variables")?.textContent ?? "{}",
);
const LOCALES: Record<string, string> = JSON.parse(
document.querySelector("#locales")?.textContent ?? "{}",
);
const CSRF_TOKEN =
document.querySelector<HTMLInputElement>("input[name=csrfmiddlewaretoken]")
?.value ?? "";
document.querySelector<HTMLInputElement>("input[name=csrfmiddlewaretoken]")
?.value ?? "";
const props = defineProps<{
layoutId: string;
layoutId: string;
}>();
watchEffect(() => {
// TODO: error handling / proper api client
isLoading.value = true;
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
)
.then((x) => x.json())
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
// TODO: error handling / proper api client
isLoading.value = true;
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
)
.then((x) => x.json())
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
});
function saveLayout(e: SubmitEvent) {
e.preventDefault();
isLoading.value = true;
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": CSRF_TOKEN,
},
body: JSON.stringify(wallet_layout.value),
},
)
.then((x) => x.json())
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
e.preventDefault();
isLoading.value = true;
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": CSRF_TOKEN,
},
body: JSON.stringify(wallet_layout.value),
},
)
.then((x) => x.json())
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
}
</script>
@@ -73,7 +76,7 @@ function saveLayout(e: SubmitEvent) {
.form-group()
Select(label="Style" v-model="wallet_layout.style" :choices="Object.values(STYLES).map(x => [x.identifier, x.name])")
StyleSettings(v-if="wallet_layout.style" v-model="wallet_layout.layout" :style="STYLES[wallet_layout.style]" :variables="VARIABLES")
StyleSettings(v-if="wallet_layout.style" v-model="wallet_layout.layout" :style="STYLES[wallet_layout.style]" :variables="VARIABLES" :locales="LOCALES")
.col-md-4
.panel.panel-default
.panel-heading Preview
@@ -81,6 +84,8 @@ function saveLayout(e: SubmitEvent) {
// TODO: Preview
pre
code {{ wallet_layout }}
pre(v-if="wallet_layout.style")
code {{ STYLES[wallet_layout.style] }}
.form-group.submit-group
button.btn.btn-primary.btn-save(type="submit") Submit
</template>

View File

@@ -1,61 +0,0 @@
<script setup lang="ts">
import { computed, reactive } from "vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
import TextContent from "./text-content.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
field: FieldGroupDefinition;
overflows: FieldGroupDefinition[];
variables: Variables;
}>();
const fieldConfig = defineModel<FieldConfig>({ required: true });
const overflowOptions = computed((): Array<[string | null, string]> => {
if (props.overflows.length) {
return [
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
[null, "Do not overflow"],
];
} else {
return [];
}
});
function addVariable() {
fieldConfig.value.entries.push({ type: "placeholder" });
}
</script>
<template lang="pug">
.panel.panel-default
.panel-heading
h3.panel-title {{ field.name }}
.panel-body
.form-group
span.text-muted These fields appear somewhere and are visible too.
h4 {{ gettext("Content") }}
.row.form-group(v-for="n in fieldConfig.entries.length")
.col-md-5
Input(:label="gettext('Label')" v-model="fieldConfig.entries[n-1].label")
.col-md-6(v-if='field.entry_type == "text"')
TextContent(v-model="fieldConfig.entries[n-1]"
:variables="props.variables")
.col-md-6(v-else-if='field.entry_type == "image"')
Select(:label="gettext('Content')"
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')}}
button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus
span.sr-only {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</template>

View File

@@ -0,0 +1,28 @@
<script setup lang="ts">
import { useId, 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") {
const oldVal = modelValue.value;
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")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -25,7 +25,7 @@ watchEffect(() => {
<template lang="pug">
template(v-if="choices.length >= 1")
label.control-label(:for="id") {{ props.label }}
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs")
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 }}
</template>

View File

@@ -0,0 +1,73 @@
<script setup lang="ts">
import { computed, reactive, watchEffect } from "vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: FieldGroupDefinition;
overflows: FieldGroupDefinition[];
variables: Variables;
locales: Record<string, string>;
}>();
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({ required: true });
const overflowOptions = computed((): Array<[string | null, string]> => {
if (props.overflows.length) {
return [
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
[null, "Do not overflow"],
];
} else {
return [];
}
});
function addVariable() {
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
}
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {overflow: null, entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries))};
}
if (fieldConfig.value && !fieldConfig.value.entries) {
fieldConfig.value.entries = JSON.parse(JSON.stringify(props.fieldgroup.default_entries))
}
});
</script>
<template lang="pug">
.panel.panel-default
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig")
.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')}}
button.btn.btn-default(type="button" @click="addVariable")
i.fa.fa-plus
span.sr-only {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: FieldGroupDefinition;
}>();
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
</script>
<template lang="pug">
.panel.panel-default
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body
.form-group
span.text-muted These fields appear somewhere and are visible too.
</template>

View File

@@ -1,44 +1,41 @@
<script setup lang="ts">
import { computed, watchEffect } from "vue";
import FieldSettings from "./field-settings.vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
variables: VariableConfig
style?: Style;
variables: VariableConfig
style?: Style;
locales: Record<string, string>;
}>();
const layout = defineModel<LayoutData>();
watchEffect(() => {
if (layout.value === undefined) {
return
}
if (layout.value.fields === undefined) {
layout.value.fields = {};
}
if (props.style) {
for (const field of props.style.fields) {
if (!(field.identifier in layout.value.fields)) {
layout.value.fields[field.identifier] = {
entries: JSON.parse(JSON.stringify(field.default_entries)),
overflow: null,
};
}
}
}
if (layout.value === undefined) {
return
}
if (layout.value.fieldgroups === undefined) {
layout.value.fieldgroups = {};
}
});
</script>
<template lang="pug">
h2.h3 {{ gettext("Field Groups") }}
FieldSettings(v-if="props.style"
v-for="(field, fieldId) in props.style.fields"
v-model="layout.fields[field.identifier]"
:field="field"
:overflows="props.style.fields.slice(fieldId + 1).filter(x => x.entry_type === field.entry_type)"
:variables="variables[field.entry_type]"
)
template(v-if="props.style && layout.fieldgroups"
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups")
PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup"
:overflows="props.style.fieldgroups.slice(fieldgroupId + 1).filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
:variables="variables[fieldgroup.content_type]"
:locales="locales"
)
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup")
</template>

View File

@@ -1,14 +1,41 @@
type FieldGroupDefinition = {
type BaseFieldGroupDefinition = {
type: string;
identifier: string;
entry_type: string;
name: string;
default_entries: FieldConfig[];
};
required: boolean;
}
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroup;
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: 'placeholder';
content_type: FieldContentType;
default_entries: FieldEntry[];
labels: boolean;
min_entries: number|null;
max_entries: number|null;
}
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
type: 'predefined';
}
type I18nString = string | Record<string, string>
type FieldContentType = 'text' | 'image';
type FieldEntry = {
type: 'placeholder' | FieldContentType;
label?: I18nString;
content?: string;
}
type Style = {
identifier: string;
name: string;
fields: FieldGroupDefinition[];
fieldgroups: FieldGroupDefinition[];
};
type Variable = {
@@ -19,21 +46,19 @@ type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type I18nString = string | Record<string, string>
type FieldEntry = {
type: 'placeholder' | 'text';
label?: I18nString; // TODO i18n
content?: string;
}
type FieldConfig = {
type PlaceholderFieldGroupConfig = {
entries: Array<FieldEntry>;
overflow: string | null;
};
type PredefinedFieldGroupConfig = {};
type FieldGroupConfig = PlaceholderFieldGroupConfig | PredefinedFieldGroupConfig;
type LayoutData = {
fields?: Record<string, FieldConfig>;
fieldgroups: Record<string, FieldGroupConfig>;
};
type Layout = {
@@ -41,3 +66,4 @@ type Layout = {
style?: string;
layout?: LayoutData;
};

View File

@@ -1,218 +0,0 @@
from dataclasses import dataclass, field, asdict
from typing import Literal
import enum
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from i18nfield.strings import LazyI18nString
from pretix.base.pdf import get_images, get_variables
from .models import WalletLayout
class WalletPlatform:
identifier: str
name: str
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class GooglePlatform(WalletPlatform):
identifier = "google"
name = _("Google")
class FieldType(enum.Enum):
TEXT = "text"
CODE = "qr"
IMAGE = "image"
PREDEFINED = "predefined"
# TODO: POWERED_BY ?
class BaseField:
type: str
label: LazyI18nString
content: str
def __init__(self, label: LazyI18nString, content: str):
self.label = label
self.content = content
def asdict(self):
return {"type": self.type, "label": self.label.data, "content": self.content}
class PlaceholderField(BaseField):
type = "placeholder"
class TextField(BaseField):
type = "text"
@dataclass
class FieldGroupDefinition:
name: str
identifier: str
entry_type: FieldType
min_entries: int | None = None
max_entries: int | None = None
def asdict(self):
return {
"identifier": self.identifier,
"name": self.name,
"entry_type": self.entry_type.value,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
}
@dataclass
class PlaceholderFieldGroup(FieldGroupDefinition):
entry_type: FieldType = FieldType.TEXT
default_entries: list[PlaceholderField | TextField] = field(default_factory=list) # TODO: TextField seems wrong here
def asdict(self):
asdict = super().asdict()
asdict["default_entries"] = [x.asdict() for x in self.default_entries]
return asdict
@dataclass
class PredefinedFieldGroup(FieldGroupDefinition):
entry_type: FieldType = FieldType.PREDEFINED
min_entries = 0
max_entries = 1
class PassStyle:
identifier: str # unique within platform
name: str
platform: Literal["apple"] | Literal["google"]
fields: list[FieldGroupDefinition]
# preview_image: str # TODO: preview
def asdict(self):
return {
"identifier": self.identifier,
"name": self.name,
"platform": self.platform,
"fields": [x.asdict() for x in self.fields],
}
class AppleWalletEventTicket(PassStyle):
identifier = "event_1"
name = "Event Ticket Layout 1"
platform = "apple"
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go) -> can only go down in the list
# we evaluate the fields in this order, so they overspill in this order as well (fields from primary are appended to the overspilling field before fields from secondary are etc)
fields = [
PlaceholderFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderField(LazyI18nString("logo"), "event:image")
],
entry_type=FieldType.IMAGE,
),
PlaceholderFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderField(LazyI18nString("Ticket type"), "item")
],
),
PlaceholderFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
PlaceholderFieldGroup(
identifier="headers", name=_("Header"), max_entries=3
), # TODO: header image
PlaceholderFieldGroup(
identifier="auxillary", name=_("Auxillary"), max_entries=4
),
PlaceholderFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"
class GoogleWalletEventTicket(PassStyle):
identifier = "event"
name = "Event Ticket"
platform = "google"
fields = [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
PlaceholderFieldGroup(
identifier="qrcode", name=_("QR-Code"), entry_type=FieldType.CODE
),
]
AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": GooglePlatform}
AVAILABLE_STYLES = [AppleWalletEventTicket(), GoogleWalletEventTicket()]
def get_platforms_with_styles():
platforms_with_styles = {}
for style in AVAILABLE_STYLES:
platform = style.platform
if platform not in platforms_with_styles:
platforms_with_styles[platform] = {}
platforms_with_styles[platform][style.identifier] = style
return platforms_with_styles
def get_platform_styles(platform):
platform_styles = {}
for style in AVAILABLE_STYLES:
if style.platform == platform:
platform_styles[style.identifier] = style
return platform_styles
def get_platforms():
return AVAILABLE_PLATFORMS
class PassLayout:
style: PassStyle
layout: dict
def __init__(self, style, layout):
self.style = style
self.layout = layout
def validate(self, event):
self.validate_fields(event)
def validate_fields(self, event):
placeholders = {"text": get_variables(event), "image": get_images(event)}
style_fields = self.style.fields
if "fields" not in self.layout:
raise ValidationError(_("Layout did not contain any fields"))
layout_fields = self.layout["fields"]
if not isinstance(layout_fields, dict):
raise ValidationError(_("'fields' must be dict"))
for fieldgroup in style_fields:
layout_field_data = layout_fields.get(fieldgroup.identifier, {})
if fieldgroup.min_entries and fieldgroup.min_entries < len(
layout_field_data.get('entries', [])
):
raise ValidationError(
_("At least {min_entries} must be specified for {name}").format(
min_entries=fieldgroup.min_entries, name=fieldgroup.name
)
)
# TODO: move field validation to json schema
for entry in layout_field_data.get('entries', []):
if entry['type'] not in ('placeholder', fieldgroup.entry_type.value):
raise ValidationError(_("Placeholder of wrong type \"{type}\" in {name}").format(type=entry['type'], name="fieldgroup.name"))
if entry['type'] == 'placeholder' and entry['content'] not in placeholders[fieldgroup.entry_type.value]:
raise ValidationError(_("Unknown placeholder {name}").format(name=entry['content']))

View File

@@ -0,0 +1,17 @@
from .apple import ApplePlatform, AppleWalletEventTicket
from .google import GooglePlatform, GoogleWalletEventTicket
from .base import PassLayout
AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": GooglePlatform}
AVAILABLE_STYLES = {
"apple": [AppleWalletEventTicket()],
"google": [
GoogleWalletEventTicket()
],
}
AVAILABLE_STYLES_DICT = {
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
}
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"]

View File

@@ -0,0 +1,66 @@
from .base import (
FieldEntry,
FieldEntryContentType,
FieldContentType,
ImageFieldGroup,
PlaceholderFieldGroup,
TextFieldGroup,
WalletPlatform,
PassStyle,
)
from django.utils.translation import gettext as _
from i18nfield.strings import LazyI18nString
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class AppleWalletStyle(PassStyle):
platform = ApplePlatform
class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout 1")
fieldgroups = [
ImageFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=1,
max_entries=1,
labels=False,
default_entries=[
FieldEntry(
type=FieldEntryContentType.IMAGE,
label=LazyI18nString("logo"),
content="event:image",
)
],
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
FieldEntry(
type=FieldEntryContentType.PLACEHOLDER,
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item",
)
], # TODO: support Lazyi18nproxy here
description=_("These fields appear prominently featured on the pass.")
),
TextFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
TextFieldGroup(
identifier="headers", name=_("Header"), max_entries=3
), # TODO: header image
TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
TextFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"

View File

@@ -0,0 +1,269 @@
import enum
from i18nfield.strings import LazyI18nString
import jsonschema
class WalletPlatform:
identifier: str
name: str
class FieldGroupType(enum.Enum):
PLACEHOLDER = "placeholder"
PREDEFINED = "predefined"
class FieldGroup:
type: FieldGroupType
identifier: str
name: str
description: str
required: bool = False
def __init__(self, identifier: str, name: str, description=None, required=False):
self.identifier = identifier
self.name = name
self.required = required
self.description = description or ""
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
) -> dict:
raise NotImplemented()
def asdict(self):
return {
"type": self.type.value,
"identifier": self.identifier,
"name": self.name,
"description": self.description,
"required": self.required,
}
class FieldContentType(enum.Enum):
IMAGE = "image"
TEXT = "text"
class FieldEntryContentType(enum.Enum):
IMAGE = "image"
TEXT = "text"
PLACEHOLDER = "placeholder"
class FieldEntry:
type: FieldEntryContentType
label: LazyI18nString | None
content: str
def __init__(
self, type: FieldEntryContentType, label: LazyI18nString | None, content: str
):
self.type = type
self.label = label
self.content = content
def asdict(self) -> dict:
return {"type": self.type.value, "content": self.content, "label": self.label.data if self.label else None}
class PredefinedFieldGroup(FieldGroup):
type = FieldGroupType.PREDEFINED
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
):
return {
"type": "object"
}
class PlaceholderFieldGroup(FieldGroup):
type = FieldGroupType.PLACEHOLDER
content_type: FieldContentType
default_entries: list[FieldEntry]
labels: bool
min_entries: int | None
max_entries: int | None
def __init__(
self,
identifier: str,
name: str,
content_type: FieldContentType,
description: str=None,
required=False,
default_entries=None,
min_entries=None,
max_entries=None,
labels=True,
):
super().__init__(identifier, name, description, required)
self.content_type = content_type
self.default_entries = default_entries or []
self.min_entries = min_entries
self.max_entries = max_entries
self.labels = labels
if self.required and (self.min_entries is None or self.min_entries < 1):
self.min_entries = 1
def asdict(self):
return {
**super().asdict(),
"content_type": self.content_type.value,
"default_entries": [x.asdict() for x in self.default_entries],
"labels": self.labels,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
}
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
):
placeholders = context.get("placeholders", {}).get(self.content_type.value, [])
return {
"type": "object",
"properties": {
"entries": self.entries_schema(placeholders=placeholders),
"overflow": {
"oneOf": [
{"type": "null"},
{
"type": "string",
"enum": [
f.identifier
for f in remaining_fields
if isinstance(f, PlaceholderFieldGroup)
and f.content_type == self.content_type
],
},
]
},
},
"required": ["entries"],
}
def entries_schema(self, placeholders: list[str]):
baseprops = {}
if self.labels:
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
schema = {
"type": "array",
"items": {
"type": "object",
"oneOf": [
{
"properties": {
**baseprops,
"type": {"const": "placeholder"},
"content": {"enum": placeholders},
}
},
{
"properties": {
**baseprops,
"type": {"const": self.content_type.value},
"content": {"type": "string"},
}
},
],
"required": ["type", "content"],
},
}
if self.labels:
schema["items"]["required"].append("label")
if self.min_entries is not None:
schema["minItems"] = self.min_entries
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
return schema
class TextFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.TEXT
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, **kwargs)
class ImageFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.IMAGE
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, **kwargs)
class PassStyle:
platform: type[WalletPlatform]
identifier: str # unique within platform
name: str
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go) -> can only go down in the list
# we evaluate the fields in this order, so they overspill in this order as well (fields from primary are appended to the overspilling field before fields from secondary are etc)
fieldgroups: list[FieldGroup]
def asdict(self):
return {
"platform": self.platform.identifier,
"identifier": self.identifier,
"name": self.name,
"fieldgroups": [x.asdict() for x in self.fieldgroups],
}
def layout_schema(self, context):
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
# TODO: $id
"title": self.name,
"type": "object",
"properties": {
"fieldgroups": {
"description": "Layout Field Groups",
"type": "object",
"properties": {
group.identifier: group.layout_schema(
context=context, remaining_fields=self.fieldgroups[i:]
)
for (i, group) in enumerate(self.fieldgroups)
},
"required": [
group.identifier for group in self.fieldgroups if group.required
],
}
},
"$defs": {
"I18nString": {
"oneOf": [
{"type": "string"},
{"type": "object", "additionalProperties": {"type": "string"}},
]
}
},
}
if any(group.required for group in self.fieldgroups):
schema["required"] = ["fieldgroups"]
return schema
class PassLayout:
style: PassStyle
layout: dict
def __init__(self, style, layout):
self.style = style
self.layout = layout
def validate(self, context):
schema = self.style.layout_schema(context)
try:
jsonschema.validate(self.layout, schema)
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))

View File

@@ -0,0 +1,20 @@
from .base import PassStyle, PredefinedFieldGroup, TextFieldGroup, WalletPlatform
from django.utils.translation import gettext_lazy as _
class GooglePlatform(WalletPlatform):
identifier = "google"
name = _("Google")
class GoogleWalletStyle(PassStyle):
platform = GooglePlatform
class GoogleWalletEventTicket(PassStyle):
identifier = "event"
name = "Event Ticket"
platform = GooglePlatform
fieldgroups = [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
TextFieldGroup(identifier="qrcode", name=_("QR-Code"), labels=False),
]

View File

@@ -13,6 +13,7 @@
<h1>{% trans "Edit layout" %} {{ object.name }} </h1>
{{ styles|json_script:"styles" }}
{{ variables|json_script:"variables" }}
{{ locales|json_script:"locales" }}
<div id="editor" data-layout-id="{{ object.pk }}"></div>
{% vite_hmr %}
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}

View File

@@ -1,73 +0,0 @@
{% load i18n %}
{% load bootstrap3 %}
{% load escapejson %}
{% load formset_tags %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
<div data-formset-body>
{% for form in formset %}
<div class="row formset-row" data-formset-form>
{% bootstrap_form_errors form %}
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-md-10">
{% bootstrap_field form.entry layout="inline" %}
</div>
<div class="col-md-2 text-right flip">
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
<i class="fa fa-edit"></i></button>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endfor %}
</div>
<script type="form-template" data-formset-empty-form>
{% escapescript %}
{% with form=formset.empty_form %}
<div class="row formset-row" data-formset-form>
{% bootstrap_form_errors form %}
<div class="sr-only">
{{ form.id }}
{% bootstrap_field form.DELETE form_group_class="" layout="inline" %}
{% bootstrap_field form.ORDER form_group_class="" layout="inline" %}
</div>
<div class="col-md-10">
{% bootstrap_field form.entry layout="inline" %}
</div>
<div class="col-md-2 text-right flip">
<i class="fa fa-warning hidden" data-toggle="tooltip" title=""></i>
<button type="button" class="btn btn-default hidden" data-edit-value-map data-toggle="modal"
data-target="#editValueMapModal" title="{% trans "Edit value mapping" %}">
<i class="fa fa-edit"></i></button>
<button type="button" class="btn btn-default" data-formset-move-up-button>
<i class="fa fa-arrow-up"></i></button>
<button type="button" class="btn btn-default" data-formset-move-down-button>
<i class="fa fa-arrow-down"></i></button>
<button type="button" class="btn btn-danger" data-formset-delete-button>
<i class="fa fa-trash"></i></button>
</div>
</div>
{% endwith %}
{% endescapescript %}
</script>
<p>
<button type="button" class="btn btn-default" data-formset-add>
<i class="fa fa-plus"></i> {% trans "Add field" %}</button>
</p>
</div>
{% if external_fields %}
{{ external_fields|json_script:external_fields_id }}
{% endif %}

View File

@@ -5,14 +5,26 @@ from django import forms
from django.http import Http404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import (
CreateView, DetailView, ListView
)
from django.views.generic import CreateView, DetailView, ListView
from pretix.base.pdf import get_images, get_variables
from pretix.control.permissions import EventPermissionRequiredMixin
from django.conf import settings
from .models import WalletLayout
from .styles import get_platform_styles, get_platforms
from .styles import AVAILABLE_STYLES, AVAILABLE_PLATFORMS
def get_layout_variables(event):
return {
"text": {
varname: {"label": var["label"], "editor_sample": var["editor_sample"]}
for varname, var in get_variables(event).items()
},
"image": {
varname: {"label": var["label"]}
for varname, var in get_images(event).items()
}
| {"poweredby": {"label": _("pretix-Logo")}}, # TODO: image upload
}
# TODO: should this even be a list view?
@@ -27,7 +39,7 @@ class LayoutListView(EventPermissionRequiredMixin, ListView):
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
ctx = super().get_context_data(**kwargs)
ctx["platforms"] = get_platforms()
ctx["platforms"] = AVAILABLE_PLATFORMS
return ctx
@@ -38,33 +50,28 @@ class LayoutEditorView(DetailView):
pk_url_kwarg = "layout"
def get_platform_styles(self):
if self.object.platform not in get_platforms():
if self.object.platform not in AVAILABLE_STYLES:
raise Http404(
_("Unknown platform '{platform}'").format(platform=self.object.platform)
)
return get_platform_styles(self.object.platform)
return AVAILABLE_STYLES[self.object.platform]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["styles"] = {
id: style.asdict() for id, style in self.get_platform_styles().items()
}
context["variables"] = {
"text": {
varname: {"label": var["label"], "editor_sample": var["editor_sample"]}
for varname, var in get_variables(self.request.event).items()
},
"image": {
varname: {"label": var['label']} for varname, var in get_images(self.request.event).items()
} | {"poweredby": {"label": _("pretix-Logo")}} # TODO: image upload
style.identifier: style.asdict() for style in self.get_platform_styles()
}
context["variables"] = get_layout_variables(self.request.event)
context['locales'] = {l: dict(settings.LANGUAGES).get(l, l) for l in self.request.event.settings.get('locales')}
return context
class WalletLayoutCreateForm(forms.ModelForm):
class Meta:
model = WalletLayout
fields = ("name",)
def __init__(self, *args, platform, event, **kwargs):
super().__init__(*args, **kwargs)
self.platform = platform
@@ -74,7 +81,8 @@ class WalletLayoutCreateForm(forms.ModelForm):
self.instance.platform = self.platform
self.instance.event = self.event
return super().save(*args, **kwargs)
class LayoutCreateView(CreateView):
template_name = "pretixplugins/wallet/create.html"
form_class = WalletLayoutCreateForm
@@ -82,19 +90,17 @@ class LayoutCreateView(CreateView):
@property
def platform(self):
platform = self.kwargs['platform']
if platform not in get_platforms():
raise Http404(
_("Unknown platform '{platform}'").format(platform=platform)
)
platform = self.kwargs["platform"]
if platform not in AVAILABLE_PLATFORMS:
raise Http404(_("Unknown platform '{platform}'").format(platform=platform))
return platform
def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs['platform'] = self.platform
kwargs['event'] = self.request.event
kwargs["platform"] = self.platform
kwargs["event"] = self.request.event
return kwargs
def get_success_url(self) -> str:
return reverse(
"plugins:wallet:edit",
@@ -103,4 +109,4 @@ class LayoutCreateView(CreateView):
"event": self.request.event.slug,
"layout": self.object.pk,
},
)
)