WIP: predefined preview, google api, custom placeholder class

This commit is contained in:
Kara Engelhardt
2026-07-28 19:00:11 +02:00
parent 13d2e809aa
commit 8bbf90a5d0
20 changed files with 496 additions and 325 deletions
+2 -2
View File
@@ -5,7 +5,7 @@ from .models import WalletLayout, WalletPlatformLayout
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from .views import get_layout_variables from .views import get_editor_placeholders
from rest_framework import serializers from rest_framework import serializers
@@ -34,7 +34,7 @@ class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
style = platform_styles[data["style"]] style = platform_styles[data["style"]]
layout = PassLayout(style=style, layout=data["layout"]) layout = PassLayout(style=style, layout=data["layout"])
context = {"placeholders": get_layout_variables(self.context['event'])} context = {"placeholders": get_editor_placeholders(self.context['event'])}
layout.validate(context=context) layout.validate(context=context)
return data return data
+275
View File
@@ -0,0 +1,275 @@
from collections.abc import Callable
from .signals import (
register_wallet_text_placeholders,
register_wallet_image_placeholders,
)
from django.core.files import File
from django.dispatch import receiver
from pretix.base.templatetags.money import money_filter
from django.contrib.staticfiles import finders
from django.templatetags.static import static
from django.utils.translation import gettext_lazy as _
from i18nfield.strings import LazyI18nString
class BaseWalletPlaceholder:
"""
This is the base class for all wallet placeholders.
"""
@property
def required_context(self) -> set[str]:
"""
A a set of all attribute names that need to be contained in the base context so that this placeholder is available.
"""
return set()
@property
def identifier(self) -> str:
"""The unique identifier of this placeholder"""
raise NotImplementedError()
@property
def label(self) -> LazyI18nString:
"""The human readable name of this placeholder"""
raise NotImplementedError()
@property
def control_label(self) -> LazyI18nString:
"""
The human readable name of this placeholder shown in the backend.
Defaults to `label`
"""
return self.label
def render(self, **context):
raise NotImplementedError()
def render_sample(self, **context):
raise NotImplementedError()
class BaseWalletTextPlaceholder(BaseWalletPlaceholder):
def render(self, **context) -> str | None:
"""
This method is called to generate the text that is being shown on the pass.
You will be passed the keyword arguments specified in ``required_context``.
You are expected to return a plain-text string.
"""
raise NotImplementedError()
def render_sample(self, **context) -> str:
"""
This method is called to generate a text to be used in previews.
You will be passed sample instances of the arguments specified in ``required_context``.
If those instances contain all data needed, you do not need to implement this.
"""
sample = self.render(**context)
if sample is None:
raise RuntimeError("`render` returned None when rendering a sample")
return sample
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
def render(self, **context) -> File | None:
"""
This method is called to generate the image that is being shown on the pass.
You will be passed the keyword arguments specified in ``required_context``.
You are expected to return a `File` object.
"""
raise NotImplementedError()
def render_sample(self, **context) -> str | None:
"""
This method is called to generate a text to be used in previews.
You will be passed sample instances of the arguments specified in ``required_context``.
You are expected to return a URL to a sample image or `None` if no sample can be shown.
"""
return None
class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder):
def __init__(
self,
identifier: str,
label: LazyI18nString,
args: set[str],
func: Callable[..., str | None],
sample: None | str | Callable[..., str] = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
@property
def identifier(self):
return self._identifier
@property
def label(self):
return self._label
@property
def required_context(self) -> set[str]:
return self._args
def render_sample(self, **context) -> str:
if isinstance(self._sample, Callable):
return self._sample(**context)
elif self._sample:
return self._sample
else:
return super().render_sample(**context)
class FunctionalWalletImagePlaceholder(BaseWalletImagePlaceholder):
def __init__(
self,
identifier: str,
label: LazyI18nString,
args: set[str],
func: Callable[..., File | None],
sample: None | str | Callable[..., str] = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
@property
def required_context(self) -> set[str]:
return self._args
@property
def identifier(self):
return self._identifier
@property
def label(self):
return self._label
def render_sample(self, **context) -> str | None:
if isinstance(self._sample, Callable):
return self._sample(**context)
return self._sample
class MissingContextException(Exception):
pass
class WalletPlaceholderContext:
def __init__(self, **kwargs):
self.context_args = kwargs
self.cache = {}
def _get_placeholder_context(self, placeholder: BaseWalletPlaceholder):
missing_context = placeholder.required_context - self.context_args.keys()
if missing_context:
raise MissingContextException(
f"Missing context args for '{placeholder.identifier}': {', '.join(missing_context)}"
)
return {
k: v for k, v in self.context_args.items() if k in placeholder.required_context
}
def render_placeholder(self, placeholder: BaseWalletPlaceholder):
if placeholder.identifier in self.cache:
return self.cache[placeholder.identifier]
value = self.cache[placeholder.identifier] = placeholder.render(**self._get_placeholder_context(placeholder))
return value
def render_sample(self, placeholder: BaseWalletPlaceholder):
return placeholder.render_sample(**self._get_placeholder_context(placeholder))
def get_wallet_placeholders(event):
placeholders = {
"text": {
v.identifier: v
for r, vs in register_wallet_text_placeholders.send(sender=event)
for v in vs
},
"image": {
v.identifier: v
for r, vs in register_wallet_image_placeholders.send(sender=event)
for v in vs
},
}
return placeholders
def get_static_file(name) -> File | None:
path: str | None = finders.find(name) # type: ignore
if not path:
return
return File(open(path, "rb"))
@receiver(
register_wallet_text_placeholders,
dispatch_uid="plugin_wallet_register_wallet_text_placeholders",
)
def base_text_placeholders(sender, **kwargs):
return [
FunctionalWalletTextPlaceholder("name", LazyI18nString.from_gettext("Event Name"), {"event"}, lambda event: event.name),
FunctionalWalletTextPlaceholder(
"event_slug", LazyI18nString.from_gettext("Event Slug"), {"event"}, lambda event: event.slug
),
FunctionalWalletTextPlaceholder("order", LazyI18nString.from_gettext("Order Code"), {"order"}, lambda order: order.code),
FunctionalWalletTextPlaceholder(
"total",
LazyI18nString.from_gettext("Order Total"),
{"event", "order"},
lambda event, order: money_filter(order.total, event.currency),
),
FunctionalWalletTextPlaceholder(
"order_email",LazyI18nString.from_gettext("Order Email"), {"order"}, lambda order: order.email
),
FunctionalWalletTextPlaceholder(
"price",LazyI18nString.from_gettext("Item Price"), {"event", "order_position"}, lambda event, order_position: money_filter(order_position.price, event.currency)
),
FunctionalWalletTextPlaceholder(
"secret",LazyI18nString.from_gettext("Order Secret (QR-Code-Content)"), {"order_position"}, lambda order_position: order_position.secret
),
]
@receiver(
register_wallet_image_placeholders,
dispatch_uid="plugin_wallet_register_wallet_image_placeholders",
)
def base_image_placeholders(sender, **kwargs):
return [
FunctionalWalletImagePlaceholder(
"poweredby",
LazyI18nString.from_gettext("Logo"),
set(),
# TODO: replace with paths not from another plugin
lambda: get_static_file("pretix_passbook/logo.png"),
static("pretix_passbook/logo.png"),
),
FunctionalWalletImagePlaceholder(
"poweredby_icon",
LazyI18nString.from_gettext("Icon"),
set(),
lambda: get_static_file("pretix_passbook/icon.png"),
static("pretix_passbook/icon.png"),
),
FunctionalWalletImagePlaceholder(
"example_no_preview",
LazyI18nString.from_gettext("Image with no preview"),
set(),
lambda: get_static_file("pretix_passbook/icon.png"),
),
# TODO: Image upload
]
+18 -1
View File
@@ -20,7 +20,7 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from pretix.base.signals import register_ticket_outputs, register_global_settings from pretix.base.signals import register_ticket_outputs, register_global_settings, EventPluginSignal
from .ticketoutput import OUTPUTS from .ticketoutput import OUTPUTS
def connect_signals(): def connect_signals():
@@ -35,3 +35,20 @@ def connect_signals():
register_global_settings.connect(output.get_global_settings, dispatch_uid=f"wallet_global_settings_{output.identifier}") register_global_settings.connect(output.get_global_settings, dispatch_uid=f"wallet_global_settings_{output.identifier}")
connect_signals() connect_signals()
register_wallet_text_placeholders = EventPluginSignal()
"""
This signal is sent out to get all known wallet placeholders. Receivers should return
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletTextPlaceholder.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_wallet_image_placeholders = EventPluginSignal()
"""
This signal is sent out to get all known wallet placeholders. Receivers should return
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletImagePlaceholder.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -49,8 +49,7 @@ const platformChoices = computed(() => {
}); });
const preview_layout = computed(() => { const preview_layout = computed(() => {
return store.currentPlatformStyles[store.currentPlatformLayout.style] return store.currentPlatformStyles[store.currentPlatformLayout.style]?.preview_layout;
.preview_layout;
}); });
</script> </script>
@@ -93,3 +92,20 @@ const preview_layout = computed(() => {
button.btn.btn-primary.btn-save(type="submit") Submit button.btn.btn-primary.btn-save(type="submit") Submit
</template> </template>
<style lang="css">
.walletsettings-panel .panel-heading {
.checkbox {
padding: 0;
display: inline-block;
input[type=checkbox] {
margin-top: 0;
margin-right: 5px;
margin-left: 0px;
position: relative;
top: 1px;
}
}
}
</style>
@@ -1,148 +0,0 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from "vue";
import StyleSettings from "./style-settings.vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
const gettext = (window as any).gettext;
const isLoading = ref<boolean>(true);
const wallet_layout = ref<PlatformLayout | null>(null);
const PLATFORMS: Platforms = JSON.parse(
document.querySelector("#platforms")?.textContent ?? "{}",
);
const VARIABLES: VariableConfig = JSON.parse(
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 ?? "";
const props = defineProps<{
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;
});
});
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())
.catch((x) => alert(x))
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
}
function openForm(url: string, data: Record<string, string>) {
let form = document.createElement("form");
form.target = "_blank";
form.method = "POST";
form.action = url;
form.style.display = "none";
for (var key in data) {
var input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = data[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
const currentPlatform = ref(PLATFORMS[0].identifier);
const currentLayout = computed(() => ({}));
const platformStyles = computed(() => {
for (const platform of PLATFORMS) {
if (platform.identifier === currentPlatform.value) {
return platform.styles
}
}
});
const platformLayout = computed(() => {
for (const layout of wallet_layout.value.platform_layouts) {
if (layout.platform === currentPlatform.value) {
return layout
}
}
const newLayout = {platform: currentPlatform, style: null, layout: {}};
wallet_layout.value.platform_layouts.push(newLayout);
return newLayout
});
const platformChoices = computed(() => {
return [[null, "Do not generate pass"], ...Object.values(platformStyles.value).map(x => [x.identifier, x.name])]
});
function openPreview(e: SubmitEvent) {
e.preventDefault();
openForm("../../preview/", {"csrfmiddlewaretoken": CSRF_TOKEN, "platform": currentPlatform.value, "style": platformLayout.value.style, "layout": JSON.stringify(platformLayout.value.layout)})
}
</script>
<template lang="pug">
// TODO: add :key for all `v-for`s
// TODO: i18n textfields
// TODO: proper spinner
template(v-if="isLoading") {{ gettext("Loading...") }}
form(v-else @submit="saveLayout")
.form-group
Input(label="Name" v-model="wallet_layout.name")
nav
ul.nav.nav-tabs
li(v-for="platform in PLATFORMS" :class="{'active': currentPlatform === platform.identifier}")
a(role="tab" @click="currentPlatform = platform.identifier") {{ platform.name }}
.tabbed-form.tab-content
.tab-pane.active.row
.col-md-8
Select.form-group(label="Style" v-model="platformLayout.style" :choices="platformChoices")
StyleSettings(v-if="platformLayout.style" v-model="platformLayout.layout" :style="platformStyles[platformLayout.style]" :variables="VARIABLES" :locales="LOCALES")
.col-md-4
.panel.panel-default
.panel-heading Preview
.panel-body
// TODO: Preview
pre
code {{ platformLayout }}
pre(v-if="wallet_layout.style")
code {{ platformStyles[wallet_layout.style] }}
pre
code {{ wallet_layout }}
.form-group.submit-group
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
button.btn.btn-primary.btn-save(type="submit") Submit
</template>
@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string
}>()
const modelValue = defineModel<boolean|null>();
const id = useId()
</script>
<template lang="pug">
.checkbox
label
input(:id="id" v-model="modelValue" v-bind="$attrs" type="checkbox")
| {{ props.label }}
</template>
@@ -1,12 +1,12 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, inject, reactive, watchEffect } from "vue"; import { computed, inject, reactive, watchEffect } from "vue";
import Select from "./input/select.vue"; import Select from "./input/select.vue";
import Input from "./input/input.vue"; import Checkbox from "./input/checkbox.vue";
import I18nInput from "./input/i18ninput.vue"; import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue"; import TextContent from "./text-content.vue";
import { StoreKey } from "../walletStore"; import { StoreKey } from "../walletStore";
const store = inject(StoreKey)! const store = inject(StoreKey)!;
const gettext = (window as any).gettext; const gettext = (window as any).gettext;
@@ -14,7 +14,9 @@ const props = defineProps<{
fieldgroup: PlaceholderFieldGroupDefinition; fieldgroup: PlaceholderFieldGroupDefinition;
overflows: FieldGroupDefinition[]; overflows: FieldGroupDefinition[];
}>(); }>();
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({ required: true }); const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
required: true,
});
const overflowOptions = computed((): Array<[string | null, string]> => { const overflowOptions = computed((): Array<[string | null, string]> => {
if (props.overflows.length) { if (props.overflows.length) {
@@ -32,17 +34,25 @@ function addVariable() {
} }
watchEffect(() => { watchEffect(() => {
if (!fieldConfig.value) { if (!fieldConfig.value) {
fieldConfig.value = {overflow: null, entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries))}; fieldConfig.value = {
} overflow: null,
if (fieldConfig.value && !fieldConfig.value.entries) { entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries)),
fieldConfig.value.entries = JSON.parse(JSON.stringify(props.fieldgroup.default_entries)) active:
} props.fieldgroup.required ||
props.fieldgroup.default_entries.length > 0,
};
}
if (fieldConfig.value && !fieldConfig.value.entries) {
fieldConfig.value.entries = JSON.parse(
JSON.stringify(props.fieldgroup.default_entries),
);
}
}); });
</script> </script>
<template lang="pug"> <template lang="pug">
.panel.panel-default .panel.panel-default.walletsettings-panel
.panel-heading .panel-heading
h3.panel-title {{ fieldgroup.name }} h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig") .panel-body(v-if="fieldConfig")
@@ -1,4 +1,8 @@
<script setup lang="ts"> <script setup lang="ts">
import { watchEffect } from 'vue';
import Input from './input/input.vue';
import Checkbox from './input/checkbox.vue';
const gettext = (window as any).gettext; const gettext = (window as any).gettext;
const props = defineProps<{ const props = defineProps<{
@@ -6,13 +10,20 @@ const props = defineProps<{
}>(); }>();
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true }); const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {active: props.fieldgroup.required};
}
});
</script> </script>
<template lang="pug"> <template lang="pug">
.panel.panel-default .panel.panel-default.walletsettings-panel
.panel-heading .panel-heading
h3.panel-title {{ fieldgroup.name }} h3.panel-title.form-inline
.panel-body .form-group
Checkbox(:label="fieldgroup.name" v-model="fieldConfig.active")
.panel-body(v-if="!!fieldgroup.description")
.form-group .form-group
span.text-muted These fields appear somewhere and are visible too. span.text-muted {{ fieldgroup.description }}
</template> </template>
@@ -2,20 +2,26 @@
import { computed, inject, reactive, watchEffect } from "vue"; import { computed, inject, reactive, watchEffect } from "vue";
import { StoreKey } from "../../walletStore"; import { StoreKey } from "../../walletStore";
import PlaceholderFieldgroupPreview from "./placeholder-fieldgroup-preview.vue"; import PlaceholderFieldgroupPreview from "./placeholder-fieldgroup-preview.vue";
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
const store = inject(StoreKey)! const store = inject(StoreKey)!;
const props = defineProps<{ const props = defineProps<{
config: PreviewFieldgroup; config: PreviewFieldgroup;
}>(); }>();
const style_def = Object.fromEntries(store.currentPlatformStyles[store.currentPlatformLayout.style].fieldgroups.map(x => [x.identifier, x]))[props.config.fieldgroup] const style_def = computed(() => {
return Object.fromEntries(
store.currentPlatformStyles[
store.currentPlatformLayout.style
].fieldgroups.map((x) => [x.identifier, x]),
)[props.config.fieldgroup];
});
</script> </script>
<template lang="pug"> <template lang="pug">
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def") PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def")
PlaceholderFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def") PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def")
//- // TODO: support predefined group //- // TODO: support predefined group
//- div(:style="{'flex-grow': config.relSize}") //- div(:style="{'flex-grow': config.relSize}")
//- div(style="background-color: gray; display: flex; flex-direction: row;") //- div(style="background-color: gray; display: flex; flex-direction: row;")
@@ -21,7 +21,6 @@ const props = defineProps<{ layout: Array<PreviewLayout> }>();
width: 100%; width: 100%;
aspect-ratio: 9 / 11; aspect-ratio: 9 / 11;
max-width: 360px; max-width: 360px;
max-height: 440px;
vertical-align: top; vertical-align: top;
display: inline-block; display: inline-block;
@@ -12,7 +12,6 @@ const props = defineProps<{
</script> </script>
<template lang="pug"> <template lang="pug">
// TODO: support predefined group
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}") div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
div.fieldgroup-item(v-for="{ entry, label, content } of store.currentLayoutFieldContent[config.fieldgroup]") div.fieldgroup-item(v-for="{ entry, label, content } of store.currentLayoutFieldContent[config.fieldgroup]")
div.fieldgroup-item-qrcode(v-if="style_def.display == 'code'") {{ label }} div.fieldgroup-item-qrcode(v-if="style_def.display == 'code'") {{ label }}
@@ -9,19 +9,18 @@ const props = defineProps<{
config: PreviewFieldgroup; config: PreviewFieldgroup;
style_def: PredefinedFieldGroupDefinition; style_def: PredefinedFieldGroupDefinition;
}>(); }>();
const isActive = computed(
() =>
store.currentPlatformLayout.layout.fieldgroups[props.style_def.identifier]?.active,
);
</script> </script>
<template lang="pug"> <template lang="pug">
div {{ config.fieldgroup }} div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
// TODO: support predefined group div.fieldgroup-item(v-for="{ label, content } of config.sample")
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}") div.fieldgroup-label.nowrap(v-if="!!label") {{ label }}
div.fieldgroup-item(v-for="{ entry, label, content } of store.currentLayoutFieldContent[config.fieldgroup]") div.nowrap.content(v-if="!!content" :class="config.display") {{ content }}
div.fieldgroup-item-qrcode(v-if="style_def.display == 'code'") {{ label }}
template(v-else)
div.fieldgroup-label.nowrap(v-if="style_def.display == 'with_label'") {{ label }}
div.nowrap.content(v-if="style_def.content_type == 'text'" :class="config.display") {{ content }}
img.fieldgroup-item-image(v-else-if="style_def.content_type == 'image' && content" :src="i18nstringLocalize(content)")
</template> </template>
@@ -31,7 +30,7 @@ const props = defineProps<{
min-width: 0; min-width: 0;
display: flex; display: flex;
gap: 0.5em; gap: 0.5em;
min-height: 2lh; min-height: 2lh;
} }
.fieldgroup-label { .fieldgroup-label {
font-weight: bold; font-weight: bold;
@@ -40,26 +39,26 @@ const props = defineProps<{
.fieldgroup-item { .fieldgroup-item {
flex: 0 1 100%; flex: 0 1 100%;
min-width: 0; min-width: 0;
width: 100%; width: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
justify-content: center; justify-content: center;
} }
.fieldgroup-item-image { .fieldgroup-item-image {
max-height: 3lh; max-height: 3lh;
} }
.fieldgroup-item-qrcode { .fieldgroup-item-qrcode {
font-weight: bold; font-weight: bold;
background-color: lightgray; background-color: lightgray;
width: 50%; width: 50%;
aspect-ratio: 1; aspect-ratio: 1;
margin: auto; margin: auto;
/* center content */ /* center content */
display: flex; display: flex;
align-items: center; align-items: center;
text-align: center; text-align: center;
padding: 1em; padding: 1em;
overflow-wrap: anywhere; overflow-wrap: anywhere;
} }
.nowrap { .nowrap {
@@ -74,6 +73,6 @@ const props = defineProps<{
font-size: 1.5em; font-size: 1.5em;
} }
.tight { .tight {
line-height: 1; line-height: 1;
} }
</style> </style>
@@ -3,6 +3,7 @@ type BaseFieldGroupDefinition = {
identifier: string; identifier: string;
name: string; name: string;
required: boolean; required: boolean;
description: string;
}; };
type FieldGroupDefinition = type FieldGroupDefinition =
@@ -50,7 +51,7 @@ type Style = {
type Variable = { type Variable = {
label: string; label: string;
editor_sample: I18nString; sample: string;
}; };
type Platform = { type Platform = {
@@ -67,9 +68,12 @@ type Platforms = Platform[];
type PlaceholderFieldGroupConfig = { type PlaceholderFieldGroupConfig = {
entries: Array<FieldEntry>; entries: Array<FieldEntry>;
overflow: string | null; overflow: string | null;
active: boolean;
}; };
type PredefinedFieldGroupConfig = {}; type PredefinedFieldGroupConfig = {
active: boolean;
};
type FieldGroupConfig = type FieldGroupConfig =
| PlaceholderFieldGroupConfig | PlaceholderFieldGroupConfig
@@ -72,7 +72,7 @@ export function createWalletStore(config: {
if (entry.type == "custom") { if (entry.type == "custom") {
value = i18nstringLocalize(entry.content); value = i18nstringLocalize(entry.content);
} else if (entry.type == "placeholder") { } else if (entry.type == "placeholder") {
value = placeholder.editor_sample; value = placeholder?.sample || `(unknown placeholder: ${entry.content})`;
} }
content[fieldgroup.identifier].push({ content[fieldgroup.identifier].push({
entry, entry,
@@ -185,9 +185,12 @@ export function createWalletStore(config: {
entries: JSON.parse( entries: JSON.parse(
JSON.stringify(newFieldGroups[key].default_entries), JSON.stringify(newFieldGroups[key].default_entries),
), ),
active: newFieldGroups[key].required || newFieldGroups[key].default_entries.length > 0
}; };
} else { } else {
this.currentPlatformLayout.layout.fieldgroups[key] = {}; this.currentPlatformLayout.layout.fieldgroups[key] = {
active: newFieldGroups[key].required
};
} }
} }
@@ -15,14 +15,6 @@ AVAILABLE_STYLES_DICT = {
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items() plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
} }
# TODO? move to models?
def get_platform(identifier: str):
for platform in AVAILABLE_PLATFORMS:
if platform.identifier == identifier:
return platform
def get_style(platform: str, identifier: str): def get_style(platform: str, identifier: str):
return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier) return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier)
+6 -6
View File
@@ -32,7 +32,7 @@ class ApplePlatform(WalletPlatform):
@classmethod @classmethod
def generate(cls, layout: PassLayout, op: OrderPosition): def generate(cls, layout: PassLayout, op: OrderPosition):
from ..views import get_layout_variables context = cls.get_context(op)
order = op.order order = op.order
event = order.event event = order.event
@@ -49,9 +49,7 @@ class ApplePlatform(WalletPlatform):
op.pk, op.pk,
) )
context = { context.update({
"placeholders": get_layout_variables(op.order.event),
"evaluation_context": [op, order, order.event],
"ca_certificate": order.event.settings.wallet_apple_ca_certificate.read(), "ca_certificate": order.event.settings.wallet_apple_ca_certificate.read(),
"certificate": order.event.settings.wallet_apple_certificate.read(), "certificate": order.event.settings.wallet_apple_certificate.read(),
"key": order.event.settings.wallet_apple_key.read(), "key": order.event.settings.wallet_apple_key.read(),
@@ -63,8 +61,7 @@ class ApplePlatform(WalletPlatform):
"passTypeIdentifier": order.event.settings.wallet_apple_pass_type_id, "passTypeIdentifier": order.event.settings.wallet_apple_pass_type_id,
"teamIdentifier": order.event.settings.wallet_apple_team_id, "teamIdentifier": order.event.settings.wallet_apple_team_id,
"serialNumber": serialNumber, "serialNumber": serialNumber,
"locales": event.settings.locales, })
}
data = layout.generate(context) data = layout.generate(context)
return filename, "application/vnd.apple.pkpass", data return filename, "application/vnd.apple.pkpass", data
@@ -234,6 +231,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
content="poweredby", content="poweredby",
) )
], ],
required=True
), ),
ImageFieldGroup( ImageFieldGroup(
identifier="logo", identifier="logo",
@@ -245,6 +243,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
content="poweredby", content="poweredby",
) )
], ],
required=True
), ),
TextFieldGroup( TextFieldGroup(
identifier="logo_text", identifier="logo_text",
@@ -265,6 +264,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
) )
], # TODO: support Lazyi18nproxy here ], # TODO: support Lazyi18nproxy here
description=_("These fields appear prominently featured on the pass."), description=_("These fields appear prominently featured on the pass."),
required=True
), ),
TextFieldGroup( TextFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4 identifier="secondary", name=_("Secondary"), max_entries=4
+21 -4
View File
@@ -1,12 +1,31 @@
import enum import enum
from typing import Any
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
import jsonschema import jsonschema
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from pretix.base.models import OrderPosition
class WalletPlatform: class WalletPlatform:
identifier: str identifier: str
name: str name: str
@classmethod
def generate(cls, layout: "PassLayout", op: OrderPosition) -> Any: # TODO: Typing
pass
@classmethod
def get_context(cls, op):
from ..placeholders import get_wallet_placeholders, WalletPlaceholderContext
order = op.order
event = order.event
return {
"placeholders": get_wallet_placeholders(event),
"placeholder_context": WalletPlaceholderContext(event=event, order=order, order_position=op),
"locale": event.settings.locale, # TODO: should probably be order locale
"locales": event.settings.locales,
}
class FieldGroupType(enum.Enum): class FieldGroupType(enum.Enum):
PLACEHOLDER = "placeholder" PLACEHOLDER = "placeholder"
@@ -287,11 +306,9 @@ class PassStyle:
.get(content) .get(content)
) )
if placeholder: if placeholder:
placeholder_value = placeholder["evaluate"]( placeholder_value = context['placeholder_context'].render_placeholder(placeholder)
*context.get("evaluation_context", [])
)
if placeholder_value: if placeholder_value:
return placeholder["label"], placeholder_value return placeholder.label, placeholder_value
return None, None return None, None
+34 -63
View File
@@ -10,7 +10,7 @@ from .base import (
TextFieldGroup, TextFieldGroup,
WalletPlatform, WalletPlatform,
) )
from django.utils.translation import gettext_lazy as _, gettext from django.utils.translation import gettext as _
import json import json
from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject
@@ -72,7 +72,7 @@ def get_translated_dict(string, locales):
for locale in locales: for locale in locales:
translation.activate(locale) translation.activate(locale)
translated[locale] = gettext(string) translated[locale] = _(string)
translation.deactivate() translation.deactivate()
return translated return translated
@@ -80,7 +80,7 @@ def get_translated_dict(string, locales):
def get_translated_string(string, locale): def get_translated_string(string, locale):
translation.activate(locale) translation.activate(locale)
translated = gettext(string) translated = _(string)
translation.deactivate() translation.deactivate()
return translated return translated
@@ -92,26 +92,24 @@ class GooglePlatform(WalletPlatform):
@classmethod @classmethod
def generate(cls, layout: PassLayout, op: OrderPosition): def generate(cls, layout: PassLayout, op: OrderPosition):
from ..views import get_layout_variables context = cls.get_context(op)
order = op.order order = op.order
event = order.event event = order.event
context = { context.update(
"placeholders": get_layout_variables(event), # TODO: move to higher class {
"evaluation_context": [op, order, event], "credentials": event.settings.get("wallet_google_credentials").read(),
"credentials": event.settings.get("wallet_google_credentials").read(), "issuerName": event.organizer.name,
"locale": event.settings.locale, "eventName": event.name,
"locales": event.settings.locales, # TODO: use other classId and objectId in preview mode
"issuerName": event.organizer.name, "classId": get_class_id(event),
"eventName": event.name, "objectId": get_object_id(op),
# TODO: use other classId and objectId in preview mode "homepageUrl": eventreverse_absolute(event, "presale:event.index"),
"classId": get_class_id(event), # TODO: add webhook view & register in pass
"objectId": get_object_id(op), # "webhookUrl": eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",)
"homepageUrl": eventreverse_absolute(event, "presale:event.index"), }
# TODO: add webhook view & register in pass )
"webhookUrl": "", # eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",))
}
data = layout.generate(context) data = layout.generate(context)
return "url", "text/plain", data return "url", "text/plain", data
@@ -135,8 +133,9 @@ class GoogleWalletStyle(PassStyle):
get_translated_string("Website", context["locale"]), get_translated_string("Website", context["locale"]),
get_translated_dict("Website", context["locales"]), get_translated_dict("Website", context["locales"]),
) )
# TODO: add webhook view & register in pass
# output_class.callback_url(context['webhookUrl']) if context.get("webhookUrl"): # TODO: enforce that it exists
output_class.callback_url(context["webhookUrl"])
# TODO: move to pass settings or set defaults # TODO: move to pass settings or set defaults
# if (event.settings.get('ticketoutput_googlepaypasses_latitude') # if (event.settings.get('ticketoutput_googlepaypasses_latitude')
@@ -249,55 +248,21 @@ class GoogleWalletStyle(PassStyle):
class_object = self._generate_class(layout, context, fields) class_object = self._generate_class(layout, context, fields)
ticket_object = self._generate_object(layout, context, fields) ticket_object = self._generate_object(layout, context, fields)
# TODO: privacy screen
class_object = comms.put_item(ClassType.eventTicketClass, class_object['id'], class_object)
ticket_object = comms.put_item(ObjectType.eventTicketObject, ticket_object['id'], ticket_object)
generated_jwt = comms.sign_jwt( generated_jwt = comms.sign_jwt(
ButtonJWT( ButtonJWT(
origins=[settings.SITE_URL], origins=[settings.SITE_URL],
issuer=comms.client_email, issuer=comms.client_email,
event_ticket_classes=[class_object],
event_ticket_objects=[ticket_object], event_ticket_objects=[ticket_object],
skinny=False, skinny=True,
) )
) )
return "https://pay.google.com/gp/v/save/%s" % generated_jwt return "https://pay.google.com/gp/v/save/%s" % generated_jwt
# from ..views import get_layout_variables
# order = op.order
# event = order.event
# filename = "{}-{}.pkpass".format(order.event.slug, order.code)
# ticket = str(op.item.name)
# if op.variation:
# ticket += " - " + str(op.variation)
# serialNumber = "%s-%s-%s-%d" % (
# order.event.organizer.slug,
# order.event.slug,
# order.code,
# op.pk,
# )
# context = {
# "placeholders": get_layout_variables(op.order.event),
# "evaluation_context": [op, order, order.event],
# "ca_certificate": order.event.settings.wallet_apple_ca_certificate.read(),
# "certificate": order.event.settings.wallet_apple_certificate.read(),
# "key": order.event.settings.wallet_apple_key.read(),
# "password": order.event.settings.wallet_apple_key_password,
# "description": _("Ticket for {event} ({product})").format( # TODO: i18n
# event=event.name, product=ticket
# ),
# "organizationName": event.organizer.name,
# "passTypeIdentifier": order.event.settings.wallet_apple_pass_type_id,
# "teamIdentifier": order.event.settings.wallet_apple_team_id,
# "serialNumber": serialNumber,
# "locales": event.settings.locales,
# }
# data = layout.generate(context)
# return filename, "application/vnd.apple.pkpass", data
class GoogleWalletEventTicket(GoogleWalletStyle): class GoogleWalletEventTicket(GoogleWalletStyle):
identifier = "event" identifier = "event"
@@ -352,17 +317,23 @@ class GoogleWalletEventTicket(GoogleWalletStyle):
{"value": "12:34", "label": "Time"}, {"value": "12:34", "label": "Time"},
] ]
}, },
{"fieldgroup": "seating"}, {"fieldgroup": "seating",
"sample": [
{"content": "5", "label": "Row"},
{"content": "2", "label": "Seat"},
]
},
{"fieldgroup": "code"}, {"fieldgroup": "code"},
] ]
] ]
def _generate_object(self, layout: PassLayout, context, fields): def _generate_object(self, layout: PassLayout, context, fields):
output_object = super()._generate_object(layout, context, fields) output_object = super()._generate_object(layout, context, fields)
if fields["code"]: if fields["code"]:
output_object.barcode(Barcode.qrCode, fields["code"][0]["value"], fields["code"][0]["value"]) output_object.barcode(
Barcode.qrCode, fields["code"][0]["value"], fields["code"][0]["value"]
)
# output_object.reservation_info("%s-%s" % (op.order.event.slug, op.order.code)) # output_object.reservation_info("%s-%s" % (op.order.event.slug, op.order.code))
# output_object.ticket_holder_name(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')) # output_object.ticket_holder_name(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''))
+1 -4
View File
@@ -26,13 +26,10 @@ from pretix.base.models import Event
from pretix.base.settings import SettingsSandbox from pretix.base.settings import SettingsSandbox
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from .styles import AVAILABLE_STYLES_DICT from .styles.base import WalletPlatform
from .styles.base import PassLayout, WalletPlatform
from .styles.apple import ApplePlatform from .styles.apple import ApplePlatform
from .styles.google import GooglePlatform from .styles.google import GooglePlatform
from collections import OrderedDict from collections import OrderedDict
from .models import WalletLayout
from .views import get_layout_variables
from django import forms from django import forms
from .forms import CertificateFileField, validate_rsa_privkey from .forms import CertificateFileField, validate_rsa_privkey
from pretix.control.forms import ClearableBasenameFileInput from pretix.control.forms import ClearableBasenameFileInput
+17 -34
View File
@@ -29,38 +29,24 @@ from django.contrib import messages
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.templatetags.static import static from django.templatetags.static import static
from .placeholders import get_wallet_placeholders, WalletPlaceholderContext
def get_layout_variables(event):
return {
"text": get_variables(event),
"image": get_images(event)
| {
"poweredby": {
"label": _("pretix-Logo"),
"evaluate": lambda *_: open(
finders.find("pretix_passbook/logo.png"), "rb"
),
"editor_sample": static("pretix_passbook/logo.png")
},
"poweredby_icon": {
"label": _("pretix-Icon"),
"evaluate": lambda *_: open(
finders.find("pretix_passbook/icon.png"), "rb"
),
"editor_sample": static("pretix_passbook/icon.png")
},
}, # TODO: image upload
}
def get_editor_variables(event): def get_editor_placeholders(event):
return { with (
t: { rolledback_transaction(),
vid: {"label": v.get("label"), "editor_sample": v.get("editor_sample")} language(event.settings.locale, event.settings.region),
for vid, v in vs.items() ):
p = get_preview_position(event)
context = WalletPlaceholderContext(event=event, order=p.order, order_position=p)
placeholders = {
t: {
pid: {"label": str(p.label), "sample": str(context.render_sample(p))}
for pid, p in ps.items()
}
for t, ps in get_wallet_placeholders(event).items()
} }
for t, vs in get_layout_variables(event).items() return placeholders
}
class WalletLayoutMixin: class WalletLayoutMixin:
@@ -97,10 +83,7 @@ class LayoutEditorView(LayoutDetailView):
} }
for platform in AVAILABLE_PLATFORMS for platform in AVAILABLE_PLATFORMS
] ]
# context["styles"] = { context["variables"] = get_editor_placeholders(self.request.event)
# style.identifier: style.asdict() for style in self.get_platform_styles()
# }
context["variables"] = get_editor_variables(self.request.event)
context["locales"] = { context["locales"] = {
l: dict(settings.LANGUAGES).get(l, l) l: dict(settings.LANGUAGES).get(l, l)
for l in self.request.event.settings.get("locales") for l in self.request.event.settings.get("locales")
@@ -190,7 +173,7 @@ class LayoutPreviewView(EventPermissionRequiredMixin, View):
): ):
p = get_preview_position(request.event) p = get_preview_position(request.event)
layout = PassLayout(style=style, layout=layout) layout = PassLayout(style=style, layout=layout)
context = {"placeholders": get_layout_variables(event)} context = {"placeholders": get_wallet_placeholders(event)}
layout.validate(context=context) layout.validate(context=context)
fname, mimet, data = platform.generate(layout, p) fname, mimet, data = platform.generate(layout, p)