mirror of
https://github.com/pretix/pretix.git
synced 2026-08-11 10:57:00 +00:00
WIP
This commit is contained in:
@@ -37,5 +37,5 @@ class WalletApp(AppConfig):
|
||||
description = _("Issue wallet passes for tickets (e.g. apple wallet, google wallet)")
|
||||
|
||||
def ready(self):
|
||||
from . import signals # NOQA
|
||||
from . import receivers # NOQA
|
||||
|
||||
|
||||
@@ -27,8 +27,6 @@ from pretix.base.models import LoggedModel, OrderPosition
|
||||
from django_scopes import ScopedManager
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from pretix.plugins.wallet.styles import get_style
|
||||
from pretix.plugins.wallet.styles.base import PassStyle
|
||||
|
||||
|
||||
class WalletLayout(LoggedModel):
|
||||
@@ -68,6 +66,8 @@ class WalletPlatformLayout(LoggedModel):
|
||||
|
||||
@property
|
||||
def pass_layout(self):
|
||||
from pretix.plugins.wallet.styles import get_style
|
||||
|
||||
style = get_style(self.platform, self.style)
|
||||
if style:
|
||||
return style(event=self.parent.event, layout=self.layout)
|
||||
|
||||
@@ -181,6 +181,11 @@ class WalletPlaceholderContext:
|
||||
k: v for k, v in self.context_args.items() if k in placeholder.required_context
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def is_available(cls, placeholder: BaseWalletPlaceholder, context_args: set[str]):
|
||||
missing_context = placeholder.required_context - context_args
|
||||
return not missing_context
|
||||
|
||||
def render_placeholder(self, placeholder: BaseWalletPlaceholder):
|
||||
if placeholder.identifier in self.cache:
|
||||
return self.cache[placeholder.identifier]
|
||||
@@ -192,7 +197,7 @@ class WalletPlaceholderContext:
|
||||
return placeholder.render_sample(**self._get_placeholder_context(placeholder))
|
||||
|
||||
|
||||
def get_wallet_placeholders(event):
|
||||
def get_wallet_placeholders(event) -> dict[str, dict[str, BaseWalletPlaceholder]]:
|
||||
placeholders = {
|
||||
"text": {
|
||||
v.identifier: v
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from pretix.base.signals import register_ticket_outputs, register_global_settings, EventPluginSignal
|
||||
from .ticketoutput import OUTPUTS
|
||||
|
||||
def connect_signals():
|
||||
for output in OUTPUTS:
|
||||
# DIY functools.partial to make get_defining_app happy
|
||||
def get_register_func(o):
|
||||
def register(sender, **kwargs):
|
||||
return o
|
||||
return register
|
||||
register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"wallet_output_{output.identifier}")
|
||||
if hasattr(output, "get_global_settings"):
|
||||
register_global_settings.connect(output.get_global_settings, dispatch_uid=f"wallet_global_settings_{output.identifier}")
|
||||
|
||||
connect_signals()
|
||||
@@ -20,22 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from pretix.base.signals import register_ticket_outputs, register_global_settings, EventPluginSignal
|
||||
from .ticketoutput import OUTPUTS
|
||||
|
||||
def connect_signals():
|
||||
for output in OUTPUTS:
|
||||
# DIY functools.partial to make get_defining_app happy
|
||||
def get_register_func(o):
|
||||
def register(sender, **kwargs):
|
||||
return o
|
||||
return register
|
||||
register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"wallet_output_{output.identifier}")
|
||||
if hasattr(output, "get_global_settings"):
|
||||
register_global_settings.connect(output.get_global_settings, dispatch_uid=f"wallet_global_settings_{output.identifier}")
|
||||
|
||||
connect_signals()
|
||||
|
||||
from pretix.base.signals import EventPluginSignal
|
||||
|
||||
register_wallet_text_placeholders = EventPluginSignal()
|
||||
"""
|
||||
|
||||
+45
-33
@@ -11,43 +11,54 @@ const store = inject(StoreKey)!;
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: PlaceholderFieldGroupDefinition;
|
||||
overflows: FieldGroupDefinition[];
|
||||
fieldgroup: PlaceholderFieldGroupDefinition;
|
||||
overflows: FieldGroupDefinition[];
|
||||
}>();
|
||||
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
|
||||
required: true,
|
||||
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 [];
|
||||
}
|
||||
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: "" });
|
||||
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {
|
||||
overflow: null,
|
||||
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),
|
||||
);
|
||||
}
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {
|
||||
overflow: null,
|
||||
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),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const placeholderChoices = computed(() => {
|
||||
const availableContext = new Set(props.fieldgroup.context_args);
|
||||
const choices = Object.entries(store.variables.text)
|
||||
.filter(([_, { required_context }]) =>
|
||||
new Set(required_context).isSubsetOf(availableContext),
|
||||
)
|
||||
.map(([k, v]): [string, string] => [k, v.label]);
|
||||
choices.push(["other", gettext("Other…")]);
|
||||
return choices;
|
||||
});
|
||||
</script>
|
||||
|
||||
@@ -66,21 +77,22 @@ watchEffect(() => {
|
||||
th(:class="'col-md-' + (fieldgroup.display == 'with_label' ? '6' : '11')") {{ gettext('Content') }}
|
||||
th.col-xs-1
|
||||
tbody
|
||||
tr(v-for="n,i in fieldConfig.entries.length" :key="i")
|
||||
tr(v-for="n, i in fieldConfig.entries.length" :key="i")
|
||||
td(v-if="fieldgroup.display == 'with_label'")
|
||||
.i18n-form-group
|
||||
I18nInput(v-model="fieldConfig.entries[n-1].label")
|
||||
I18nInput(v-model="fieldConfig.entries[n - 1].label")
|
||||
td
|
||||
TextContent(v-if='fieldgroup.content_type == "text"'
|
||||
v-model="fieldConfig.entries[n-1]")
|
||||
v-model="fieldConfig.entries[n - 1]"
|
||||
:placeholderChoices="placeholderChoices")
|
||||
Select(v-else-if='fieldgroup.content_type == "image"'
|
||||
v-model="fieldConfig.entries[n-1].content"
|
||||
:choices="Object.entries(store.variables.image).map(([k,v]) => [k, v.label])"
|
||||
v-model="fieldConfig.entries[n - 1].content"
|
||||
:choices="Object.entries(store.variables.image).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)")
|
||||
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')}}
|
||||
span.sr-only {{ gettext('Delete') }}
|
||||
|
||||
button.btn.btn-default(type="button" @click="addVariable")
|
||||
i.fa.fa-plus
|
||||
|
||||
+5
-5
@@ -6,7 +6,7 @@ import { i18nstringLocalize } from "../../helpers";
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
config: PreviewFieldgroup;
|
||||
config: PredefinedFieldGroupConfig;
|
||||
style_def: PredefinedFieldGroupDefinition;
|
||||
}>();
|
||||
|
||||
@@ -17,10 +17,10 @@ const isActive = computed(
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
div.fieldgroup-item(v-for="{ label, content } of config.sample")
|
||||
div.fieldgroup-label.nowrap(v-if="!!label") {{ label }}
|
||||
div.nowrap.content(v-if="!!content" :class="config.display") {{ content }}
|
||||
div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
div.fieldgroup-item(v-for="{ label, content } of config.sample")
|
||||
div.fieldgroup-label.nowrap(v-if="!!label") {{ i18nstringLocalize(label) }}
|
||||
div.nowrap.content(v-if="!!content" :class="config.display") {{ i18nstringLocalize(content) }}
|
||||
|
||||
|
||||
</template>
|
||||
|
||||
+9
-3
@@ -9,14 +9,20 @@ const props = defineProps<{
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.preview-row(v-if="'children' in config" :style="{flexDirection: config.direction || 'row'}")
|
||||
RowPreview(v-for="child of config.children" :config="child")
|
||||
div.preview-row(v-if="'children' in config" :style="{ flexDirection: config.direction || 'row' }" :class="config.display")
|
||||
RowPreview(v-for="child of config.children" :config="child" )
|
||||
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config")
|
||||
FixedPreview(v-else-if="'value' in config" :config="config")
|
||||
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
.preview-row {
|
||||
width: 100%; display: flex; gap: 1em; display: flex
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
display: flex
|
||||
}
|
||||
.tight {
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
@@ -7,13 +7,11 @@ import { StoreKey } from "../walletStore";
|
||||
const store = inject(StoreKey)!
|
||||
const gettext = (window as any).gettext
|
||||
|
||||
const entry = defineModel<FieldEntry>({ required: true })
|
||||
const props = defineProps<{
|
||||
placeholderChoices: [string|null, string][];
|
||||
}>();
|
||||
|
||||
const selectChoices = computed(() =>{
|
||||
const choices = Object.entries(store.variables.text).map(([k,v]): [string, string] => [k, v.label])
|
||||
choices.push(["other", gettext("Other…")])
|
||||
return choices
|
||||
});
|
||||
const entry = defineModel<FieldEntry>({ required: true })
|
||||
|
||||
const selection = computed({
|
||||
get() {
|
||||
@@ -53,7 +51,7 @@ const textContent = computed({
|
||||
.i18n-form-group
|
||||
Select(
|
||||
v-model="selection"
|
||||
:choices="selectChoices"
|
||||
:choices="placeholderChoices"
|
||||
)
|
||||
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="store.locales")
|
||||
</template>
|
||||
|
||||
@@ -19,6 +19,7 @@ type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
display: FieldGroupDisplay;
|
||||
min_entries: number | null;
|
||||
max_entries: number | null;
|
||||
context_args: string[]
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
@@ -52,6 +53,7 @@ type Style = {
|
||||
type Variable = {
|
||||
label: string;
|
||||
sample: string;
|
||||
required_context: string[];
|
||||
};
|
||||
|
||||
type Platform = {
|
||||
|
||||
@@ -30,16 +30,24 @@ class ApplePlatform(WalletPlatform):
|
||||
name = _("Apple")
|
||||
|
||||
|
||||
class FormattedLazyI18nString:
|
||||
def __init__(self, base_str: LazyI18nString, **format_args: str):
|
||||
self.base_str = base_str
|
||||
self.format_args = format_args
|
||||
|
||||
def localize(self, language):
|
||||
return self.base_str.localize(language).format(**self.format_args)
|
||||
|
||||
|
||||
class StringResource:
|
||||
# mapping string in default event locale -> LazyI18nString
|
||||
entries: dict[str, LazyI18nString]
|
||||
entries: dict[str, LazyI18nString | FormattedLazyI18nString]
|
||||
locales: set[str]
|
||||
|
||||
def __init__(self, locales):
|
||||
self.entries = {}
|
||||
self.locales = set(locales)
|
||||
|
||||
def add_entry(self, key: str, value: LazyI18nString):
|
||||
def add_entry(self, key: str, value: LazyI18nString | FormattedLazyI18nString):
|
||||
if key in self.entries:
|
||||
raise ValueError(f"{key} already exists in this StringResource")
|
||||
self.entries[key] = value
|
||||
@@ -126,69 +134,51 @@ class AppleWalletStyle(PassStyle):
|
||||
def pass_content(self, fields, strings):
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_pass_json(self, fields, context, strings):
|
||||
def add_from_context(key):
|
||||
value = context.get(key)
|
||||
if not value:
|
||||
raise ValueError(f"{key} must be set to a truthy value")
|
||||
return value
|
||||
def generate_pass_json(self, fields, op, strings):
|
||||
ticket = str(op.item.name)
|
||||
if op.variation:
|
||||
ticket += " - " + str(op.variation)
|
||||
|
||||
description = FormattedLazyI18nString(
|
||||
LazyI18nString.from_gettext("Ticket for {event} ({product})"),
|
||||
event=self.event.name,
|
||||
product=ticket,
|
||||
)
|
||||
strings.add_entry("description", description)
|
||||
|
||||
serialNumber = "%s-%s-%s-%d" % (
|
||||
self.event.organizer.slug,
|
||||
self.event.slug,
|
||||
op.order.code,
|
||||
op.pk,
|
||||
)
|
||||
|
||||
pass_json = {
|
||||
"formatVersion": 1,
|
||||
"description": add_from_context("description"),
|
||||
"organizationName": add_from_context("organizationName"),
|
||||
"passTypeIdentifier": add_from_context("passTypeIdentifier"),
|
||||
"teamIdentifier": add_from_context("teamIdentifier"),
|
||||
"serialNumber": add_from_context("serialNumber"),
|
||||
"description": "description",
|
||||
"organizationName": self.event.organizer.name,
|
||||
"passTypeIdentifier": self.event.settings.wallet_apple_pass_type_id,
|
||||
"teamIdentifier": self.event.settings.wallet_apple_team_id,
|
||||
"serialNumber": serialNumber,
|
||||
**self.pass_content(fields, strings),
|
||||
}
|
||||
return pass_json
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
context = self.get_context(op)
|
||||
|
||||
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.update({
|
||||
"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,
|
||||
})
|
||||
|
||||
|
||||
|
||||
fields = self.get_pass_fields(layout, context)
|
||||
fields = self.get_pass_fields(op)
|
||||
|
||||
pkpass = SignedZipFile(
|
||||
context["ca_certificate"],
|
||||
context["certificate"],
|
||||
context["key"],
|
||||
context["password"],
|
||||
self.event.settings.wallet_apple_ca_certificate.read(),
|
||||
self.event.settings.wallet_apple_certificate.read(),
|
||||
self.event.settings.wallet_apple_key.read(),
|
||||
self.event.settings.wallet_apple_key_password,
|
||||
)
|
||||
strings = StringResource(locales=context["locales"])
|
||||
strings = StringResource(locales=self.event.settings.locales)
|
||||
|
||||
pass_json = self.generate_pass_json(fields, context, strings)
|
||||
pass_json = self.generate_pass_json(fields, op, strings)
|
||||
print(pass_json)
|
||||
if fields["logo"]:
|
||||
logo = fields["logo"][0]["value"]
|
||||
@@ -210,7 +200,6 @@ class AppleWalletStyle(PassStyle):
|
||||
return filename, "application/vnd.apple.pkpass", result
|
||||
|
||||
|
||||
|
||||
class AppleWalletEventTicket(AppleWalletStyle):
|
||||
identifier = "event_1"
|
||||
name = _("Event Ticket Layout 1")
|
||||
@@ -225,7 +214,8 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
content="poweredby",
|
||||
)
|
||||
],
|
||||
required=True
|
||||
required=True,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
ImageFieldGroup(
|
||||
identifier="logo",
|
||||
@@ -237,7 +227,8 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
content="poweredby",
|
||||
)
|
||||
],
|
||||
required=True
|
||||
required=True,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="logo_text",
|
||||
@@ -245,6 +236,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
default_entries=[],
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="primary",
|
||||
@@ -258,13 +250,27 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
)
|
||||
], # TODO: support Lazyi18nproxy here
|
||||
description=_("These fields appear prominently featured on the pass."),
|
||||
required=True
|
||||
required=True,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="secondary", name=_("Secondary"), max_entries=4
|
||||
identifier="secondary",
|
||||
name=_("Secondary"),
|
||||
max_entries=4,
|
||||
context_args={"event", "order", "order_position"},
|
||||
), # 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="header", name=_("Header"), max_entries=3),
|
||||
TextFieldGroup(identifier="auxiliary", name=_("Auxiliary"), max_entries=4),
|
||||
TextFieldGroup(
|
||||
identifier="header",
|
||||
name=_("Header"),
|
||||
max_entries=3,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="auxiliary",
|
||||
name=_("Auxiliary"),
|
||||
max_entries=4,
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
name=_("QR-Code"),
|
||||
@@ -275,8 +281,13 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
content="secret",
|
||||
)
|
||||
],
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="back",
|
||||
name=_("Back"),
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
TextFieldGroup(identifier="back", name=_("Back")),
|
||||
]
|
||||
preview_layout = [
|
||||
[
|
||||
|
||||
@@ -1,24 +1,33 @@
|
||||
import enum
|
||||
from typing import Any
|
||||
from typing import Any, TypedDict
|
||||
from black.nodes import is_vararg
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import jsonschema
|
||||
from django.core.exceptions import ValidationError
|
||||
from pretix.base.models import OrderPosition
|
||||
from ..placeholders import WalletPlaceholderContext, get_wallet_placeholders
|
||||
|
||||
|
||||
class WalletPlatform:
|
||||
identifier: str
|
||||
name: str
|
||||
|
||||
|
||||
class LayoutContext(TypedDict):
|
||||
placeholders: dict[str, dict]
|
||||
|
||||
|
||||
class FieldGroupType(enum.Enum):
|
||||
PLACEHOLDER = "placeholder"
|
||||
PREDEFINED = "predefined"
|
||||
|
||||
|
||||
class FieldGroupDisplay(enum.Enum):
|
||||
PLAIN = "plain"
|
||||
WITH_LABEL = "with_label"
|
||||
CODE = "code"
|
||||
|
||||
|
||||
class FieldGroup:
|
||||
type: FieldGroupType
|
||||
identifier: str
|
||||
@@ -35,7 +44,7 @@ class FieldGroup:
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: dict,
|
||||
context: LayoutContext,
|
||||
) -> dict:
|
||||
raise NotImplemented()
|
||||
|
||||
@@ -72,16 +81,19 @@ class FieldEntry[T]:
|
||||
self.content = content
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {"type": self.type.value, "content": self.content, "label": self.label.data if self.label else None}
|
||||
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
|
||||
):
|
||||
def __init__(self, content: str, label: LazyI18nString | None = None):
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
@@ -92,8 +104,11 @@ class CustomFieldEntry(FieldEntry[LazyI18nString]):
|
||||
content: LazyI18nString
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {"type": self.type.value, "content": self.content.data, "label": self.label.data if self.label else None}
|
||||
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"content": self.content.data,
|
||||
"label": self.label.data if self.label else None,
|
||||
}
|
||||
|
||||
|
||||
class PredefinedFieldGroup(FieldGroup):
|
||||
@@ -102,11 +117,10 @@ class PredefinedFieldGroup(FieldGroup):
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: dict,
|
||||
context: LayoutContext,
|
||||
):
|
||||
return {
|
||||
"type": "object"
|
||||
}
|
||||
return {"type": "object"}
|
||||
|
||||
|
||||
class PlaceholderFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PLACEHOLDER
|
||||
@@ -115,18 +129,22 @@ class PlaceholderFieldGroup(FieldGroup):
|
||||
display: FieldGroupDisplay
|
||||
min_entries: int | None
|
||||
max_entries: int | None
|
||||
context_args: set[
|
||||
str
|
||||
] # what context arguments are available when rendering this fieldgroup
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
name: str,
|
||||
content_type: FieldContentType,
|
||||
description: str="",
|
||||
description: str = "",
|
||||
required=False,
|
||||
default_entries=None,
|
||||
min_entries=None,
|
||||
max_entries=None,
|
||||
display=FieldGroupDisplay.WITH_LABEL,
|
||||
context_args: set[str] | None = None,
|
||||
):
|
||||
super().__init__(identifier, name, description, required)
|
||||
self.content_type = content_type
|
||||
@@ -134,6 +152,7 @@ class PlaceholderFieldGroup(FieldGroup):
|
||||
self.min_entries = min_entries
|
||||
self.max_entries = max_entries
|
||||
self.display = display
|
||||
self.context_args = context_args or set()
|
||||
|
||||
if self.required and (self.min_entries is None or self.min_entries < 1):
|
||||
self.min_entries = 1
|
||||
@@ -146,18 +165,26 @@ class PlaceholderFieldGroup(FieldGroup):
|
||||
"display": self.display.value,
|
||||
"min_entries": self.min_entries,
|
||||
"max_entries": self.max_entries,
|
||||
"context_args": list(sorted(self.context_args))
|
||||
}
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: dict,
|
||||
context: LayoutContext,
|
||||
):
|
||||
placeholders = list(context.get("placeholders", {}).get(self.content_type.value, {}).keys())
|
||||
content_type_placeholders = (
|
||||
context["placeholders"].get(self.content_type.value, {}).values()
|
||||
)
|
||||
available_placeholders = [
|
||||
x.identifier
|
||||
for x in content_type_placeholders
|
||||
if WalletPlaceholderContext.is_available(x, self.context_args)
|
||||
]
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": self.entries_schema(placeholders=placeholders),
|
||||
"entries": self.entries_schema(placeholders=available_placeholders),
|
||||
"overflow": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
@@ -212,7 +239,6 @@ class PlaceholderFieldGroup(FieldGroup):
|
||||
return schema
|
||||
|
||||
|
||||
|
||||
class TextFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.TEXT
|
||||
|
||||
@@ -234,23 +260,25 @@ class PassStyle:
|
||||
# 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]
|
||||
preview_layout: list | None
|
||||
|
||||
@classmethod
|
||||
def asdict(cls):
|
||||
@property
|
||||
def preview_layout(self) -> list | None:
|
||||
return None
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"identifier": cls.identifier,
|
||||
"name": cls.name,
|
||||
"fieldgroups": [x.asdict() for x in cls.fieldgroups],
|
||||
"preview_layout": cls.preview_layout
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"fieldgroups": [x.asdict() for x in self.fieldgroups],
|
||||
"preview_layout": self.preview_layout,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def layout_schema(cls, context):
|
||||
def layout_schema(self):
|
||||
context = LayoutContext(placeholders=self.placeholders)
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
# TODO: $id
|
||||
"title": cls.name,
|
||||
"title": self.name,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fieldgroups": {
|
||||
@@ -258,12 +286,12 @@ class PassStyle:
|
||||
"type": "object",
|
||||
"properties": {
|
||||
group.identifier: group.layout_schema(
|
||||
context=context, remaining_fields=cls.fieldgroups[i:]
|
||||
context=context, remaining_fields=self.fieldgroups[i:]
|
||||
)
|
||||
for (i, group) in enumerate(cls.fieldgroups)
|
||||
for (i, group) in enumerate(self.fieldgroups)
|
||||
},
|
||||
"required": [
|
||||
group.identifier for group in cls.fieldgroups if group.required
|
||||
group.identifier for group in self.fieldgroups if group.required
|
||||
],
|
||||
}
|
||||
},
|
||||
@@ -276,41 +304,37 @@ class PassStyle:
|
||||
}
|
||||
},
|
||||
}
|
||||
if any(group.required for group in cls.fieldgroups):
|
||||
if any(group.required for group in self.fieldgroups):
|
||||
schema["required"] = ["fieldgroups"]
|
||||
|
||||
return schema
|
||||
|
||||
@classmethod
|
||||
def render_placeholder(cls, context, content_type, content):
|
||||
placeholder = (
|
||||
context.get("placeholders", {})
|
||||
.get(content_type, {})
|
||||
.get(content)
|
||||
)
|
||||
def render_placeholder(self, context, content_type, content):
|
||||
placeholder = self.placeholders.get(content_type, {}).get(content)
|
||||
if placeholder:
|
||||
placeholder_value = context['placeholder_context'].render_placeholder(placeholder)
|
||||
placeholder_value = context.render_placeholder(placeholder)
|
||||
if placeholder_value:
|
||||
return placeholder.label, placeholder_value
|
||||
|
||||
return None, None
|
||||
|
||||
|
||||
def __init__(self, event, layout):
|
||||
self.event = event
|
||||
self.layout = layout
|
||||
|
||||
def get_layout_context(self):
|
||||
return {"placeholders": {}}
|
||||
self.placeholders = get_wallet_placeholders(self.event)
|
||||
|
||||
def validate(self):
|
||||
schema = self.layout_schema(self.get_layout_context())
|
||||
schema = self.layout_schema()
|
||||
try:
|
||||
jsonschema.validate(self.layout, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValidationError("Invalid layout: {}".format(str(e)))
|
||||
|
||||
def get_pass_fields(self, context):
|
||||
def get_pass_fields(self, op: OrderPosition):
|
||||
context = WalletPlaceholderContext(
|
||||
event=self.event, order=op.order, order_position=op
|
||||
)
|
||||
|
||||
fields = {}
|
||||
for group in self.fieldgroups:
|
||||
if isinstance(group, PredefinedFieldGroup):
|
||||
@@ -319,14 +343,22 @@ class PassStyle:
|
||||
elif isinstance(group, PlaceholderFieldGroup):
|
||||
group_fields = fields.get(group.identifier, [])
|
||||
if group.identifier in self.layout["fieldgroups"]:
|
||||
for field in self.layout["fieldgroups"][group.identifier]["entries"]:
|
||||
for field in self.layout["fieldgroups"][group.identifier][
|
||||
"entries"
|
||||
]:
|
||||
field_entry = {}
|
||||
if group.display == FieldGroupDisplay.WITH_LABEL:
|
||||
field_entry["label"] = LazyI18nString(field["label"])
|
||||
if field["type"] == FieldEntryType.PLACEHOLDER.value:
|
||||
label, field_entry["value"] = self.render_placeholder(context, group.content_type.value, field['content'])
|
||||
if group.display == FieldGroupDisplay.WITH_LABEL and not str(field_entry['label']) and label:
|
||||
field_entry['label'] = LazyI18nString(label)
|
||||
label, field_entry["value"] = self.render_placeholder(
|
||||
context, group.content_type.value, field["content"]
|
||||
)
|
||||
if (
|
||||
group.display == FieldGroupDisplay.WITH_LABEL
|
||||
and not str(field_entry["label"])
|
||||
and label
|
||||
):
|
||||
field_entry["label"] = LazyI18nString(label)
|
||||
|
||||
elif field["type"] == FieldEntryType.CUSTOM.value:
|
||||
field_entry["value"] = LazyI18nString(field["content"])
|
||||
@@ -337,13 +369,15 @@ class PassStyle:
|
||||
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
|
||||
)
|
||||
fields[group.identifier] = group_fields[: group.max_entries]
|
||||
if (overflow_group := self.layout["fieldgroups"][group.identifier]['overflow']):
|
||||
if overflow_group := self.layout["fieldgroups"][group.identifier][
|
||||
"overflow"
|
||||
]:
|
||||
fields.setdefault(overflow_group, [])
|
||||
fields[overflow_group] += group_fields[group.max_entries:]
|
||||
fields[overflow_group] += group_fields[group.max_entries :]
|
||||
|
||||
else:
|
||||
raise ValueError("Unknown field group")
|
||||
return fields
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
raise NotImplementedError()
|
||||
raise NotImplementedError()
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
from i18nfield.fields import LazyI18nString
|
||||
|
||||
from pretix.base.models import Event, OrderPosition
|
||||
|
||||
from .base import (
|
||||
@@ -186,11 +188,15 @@ class GoogleWalletStyle(PassStyle):
|
||||
comms = Comms(self.event.settings.get("wallet_google_credentials").read())
|
||||
|
||||
class_object = self._generate_class()
|
||||
ticket_object = self._generate_object(op, class_id=class_object['id'])
|
||||
ticket_object = self._generate_object(op, class_id=class_object["id"])
|
||||
|
||||
# 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)
|
||||
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(
|
||||
ButtonJWT(
|
||||
@@ -201,8 +207,7 @@ class GoogleWalletStyle(PassStyle):
|
||||
)
|
||||
)
|
||||
|
||||
return "https://pay.google.com/gp/v/save/%s" % generated_jwt
|
||||
|
||||
return 'googlepaypass', 'text/uri-list', 'https://pay.google.com/gp/v/save/%s' % generated_jwt
|
||||
|
||||
class GoogleWalletEventTicket(GoogleWalletStyle):
|
||||
identifier = "event"
|
||||
@@ -219,6 +224,8 @@ class GoogleWalletEventTicket(GoogleWalletStyle):
|
||||
)
|
||||
],
|
||||
),
|
||||
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
|
||||
PredefinedFieldGroup(identifier="date", name=_("Date")),
|
||||
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
@@ -230,46 +237,75 @@ class GoogleWalletEventTicket(GoogleWalletStyle):
|
||||
content="secret",
|
||||
)
|
||||
],
|
||||
context_args={"event", "order", "order_position"},
|
||||
),
|
||||
]
|
||||
preview_layout = [
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{"fieldgroup": "logo", "relSize": 1},
|
||||
{
|
||||
"value": "issuerName",
|
||||
"relSize": 3,
|
||||
"display": ["large", "centered"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{"value": "venueName", "display": "small"},
|
||||
{"value": "eventName", "display": "large"},
|
||||
],
|
||||
"direction": "column",
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{"value": "01/01/1970", "label": "Date"},
|
||||
{"value": "12:34", "label": "Time"},
|
||||
]
|
||||
},
|
||||
{"fieldgroup": "seating",
|
||||
"sample": [
|
||||
{"content": "5", "label": "Row"},
|
||||
{"content": "2", "label": "Seat"},
|
||||
]
|
||||
},
|
||||
{"fieldgroup": "code"},
|
||||
|
||||
@property
|
||||
def preview_layout(self):
|
||||
return [
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{"fieldgroup": "logo", "relSize": 1},
|
||||
{
|
||||
"value": str(self.event.organizer.name),
|
||||
"relSize": 3,
|
||||
"display": ["large", "centered"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"fieldgroup": "venue",
|
||||
"sample": [
|
||||
{"content": self.venue()[0], "label": ""},
|
||||
],
|
||||
},
|
||||
{"value": str(self.event.name), "display": "large"},
|
||||
],
|
||||
"direction": "column",
|
||||
"display": ["tight"]
|
||||
},
|
||||
{
|
||||
"fieldgroup": "date",
|
||||
"sample": [
|
||||
{"content": "01/01/1970", "label": "Date"},
|
||||
{"content": "12:34", "label": "Time"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "seating",
|
||||
"sample": [
|
||||
{"content": "5", "label": "Row"},
|
||||
{"content": "2", "label": "Seat"},
|
||||
],
|
||||
},
|
||||
{"fieldgroup": "code"},
|
||||
]
|
||||
]
|
||||
]
|
||||
|
||||
def venue(self):
|
||||
if self.event.location:
|
||||
name = {}
|
||||
address = {}
|
||||
|
||||
for key, value in self.event.location.data.items():
|
||||
lines = value.splitlines()
|
||||
name[key] = lines[0]
|
||||
# We must provide at least one address line each for the name and address - no way around it.
|
||||
if len(lines) > 1:
|
||||
address[key] = '\n'.join(value.splitlines()[1:])
|
||||
else:
|
||||
address[key] = lines[0]
|
||||
|
||||
return name, address
|
||||
return None, None
|
||||
|
||||
def _generate_object(self, op: OrderPosition, class_id: str):
|
||||
output_object = super()._generate_object(op, class_id)
|
||||
|
||||
fields = self.get_pass_fields(op)
|
||||
if fields["code"]:
|
||||
output_object.barcode(
|
||||
Barcode.qrCode, fields["code"][0]["value"], fields["code"][0]["value"]
|
||||
|
||||
@@ -40,7 +40,7 @@ def get_editor_placeholders(event):
|
||||
context = WalletPlaceholderContext(event=event, order=p.order, order_position=p)
|
||||
placeholders = {
|
||||
t: {
|
||||
pid: {"label": str(p.label), "sample": str(context.render_sample(p))}
|
||||
pid: {"label": str(p.label), "sample": str(context.render_sample(p)), "required_context": list(sorted(p.required_context))}
|
||||
for pid, p in ps.items()
|
||||
}
|
||||
for t, ps in get_wallet_placeholders(event).items()
|
||||
@@ -76,7 +76,7 @@ class LayoutEditorView(LayoutDetailView):
|
||||
"identifier": platform.identifier,
|
||||
"name": platform.name,
|
||||
"styles": {
|
||||
style.identifier: style.asdict()
|
||||
style.identifier: style(self.request.event, None).asdict()
|
||||
for style in AVAILABLE_STYLES.get(platform.identifier)
|
||||
},
|
||||
}
|
||||
@@ -174,7 +174,7 @@ class LayoutPreviewView(EventPermissionRequiredMixin, View):
|
||||
layout = style(event=event, layout=layout)
|
||||
layout.validate()
|
||||
|
||||
fname, mimet, data = platform.generate(layout, p)
|
||||
fname, mimet, data = layout.generate(p)
|
||||
resp = HttpResponse(data, content_type=mimet)
|
||||
ftype = fname.split(".")[-1]
|
||||
if not mimet.startswith("text/"):
|
||||
|
||||
Reference in New Issue
Block a user