WIP: i18n editor, start apple wallet generation

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,482 @@
from pretix.plugins.wallet.styles.base import (
PassStyle,
WalletPlatform,
PlaceholderFieldGroup,
FieldContentType,
PassLayout,
FieldGroupType,
FieldEntryType,
)
from pretix.plugins.wallet.styles.apple import SignedZipFile, StringResource
from django.utils.translation import gettext as _
import jsonschema
import pytest
from i18nfield.strings import LazyI18nString
from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization, hashes
from cryptography import x509
import datetime
import io
import zipfile
import json
class WalletTestPlatform(WalletPlatform):
identifier = "test_platform"
name = _("Test Wallet Platform")
class MinimalTestStyle(PassStyle):
platform = WalletTestPlatform
identifier = "test_style"
name = _("Test Wallet Style")
fieldgroups = []
class TicketTestStyle(PassStyle):
platform = WalletTestPlatform
identifier = "test_ticket"
name = _("Test Wallet Style Ticket")
fieldgroups = [
PlaceholderFieldGroup(
identifier="text1",
name=_("Text 1"),
content_type=FieldContentType.TEXT,
required=True,
),
PlaceholderFieldGroup(
identifier="text2",
name=_("Text 2"),
content_type=FieldContentType.TEXT,
required=False,
labels=False,
),
PlaceholderFieldGroup(
identifier="image1",
name=_("Image 1"),
content_type=FieldContentType.IMAGE,
required=False,
labels=False,
),
]
def generate(self, layout, context):
output = f"Generated Pass: {self.name}\n\n"
for group in self.fieldgroups:
if group.identifier in layout["fieldgroups"]:
output += f"Group: {group.name}\n"
if group.type == FieldGroupType.PREDEFINED:
output += "PREDEFINED\n"
else:
for field in layout["fieldgroups"][group.identifier]["entries"]:
if group.labels:
label = LazyI18nString(field["label"])
output += f"{label}: "
if field["type"] == FieldEntryType.PLACEHOLDER.value:
placeholder = (
context.get("placeholders")
.get(group.content_type.value, {})
.get(field["content"])
)
if placeholder:
output += placeholder["evaluate"](
*context.get("evaluation_context", [])
)
else:
output += f"UNKNOWN: {field['content']}"
elif field["type"] == FieldEntryType.TEXT.value:
output += str(LazyI18nString(field["content"]))
elif field["type"] == FieldEntryType.IMAGE.value:
output += f"<IMG>{field['content']}</IMG>"
output += "\n"
output += "\n"
return output
@pytest.fixture
def layout_context():
return {
"placeholders": {
"text": {"test_placeholder": {"evaluate": lambda: "test placeholder"}}
}
}
def test_schema_generation_minimal():
style = MinimalTestStyle()
context = {}
schema = style.layout_schema(context)
assert isinstance(schema, dict)
assert "properties" in schema
assert "fieldgroups" in schema["properties"]
jsonschema.validate({}, schema)
jsonschema.validate({"fieldgroups": {}}, schema)
def test_schema_ticket_generation(layout_context):
style = TicketTestStyle()
schema = style.layout_schema(layout_context)
assert isinstance(schema, dict)
assert "properties" in schema
assert "fieldgroups" in schema["properties"]
@pytest.mark.parametrize(
"layout",
[
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": "test",
"content": "test_placeholder",
}
]
}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": {"de": "test-de", "en": "test-en"},
"content": "test_placeholder",
}
]
}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{"type": "text", "label": "test", "content": "test content"}
]
}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": {"de": "test-de", "en": "test-en"},
"content": "test_placeholder",
},
{"type": "text", "label": "test", "content": "test content"},
]
}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": {"de": "test-de", "en": "test-en"},
"content": "test_placeholder",
},
{"type": "text", "label": "test", "content": "test content"},
],
"overflow": "text2",
}
}
},
],
)
def test_schema_ticket_valid(layout_context, layout):
style = TicketTestStyle()
schema = style.layout_schema(layout_context)
jsonschema.validate(layout, schema)
@pytest.mark.parametrize(
"layout",
[
{},
{"fieldgroups": {}},
{"fieldgroups": {"text1": {}}},
{"fieldgroups": {"text1": {"entries": []}}},
{"fieldgroups": {"text1": {"overflow": "test"}}},
{
"fieldgroups": {
"text1": {
"entries": [{"type": "placeholder", "content": "test_placeholder"}]
}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": [],
"content": "test_placeholder",
}
]
}
}
},
{
"fieldgroups": {
"text1": {"entries": [{"type": "text", "content": "test content"}]}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": "test",
"content": "test_placeholder",
}
],
"overflow": "invalid_group",
}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": "test",
"content": "test_placeholder",
}
],
"overflow": "image1",
}
}
},
{
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": "test",
"content": "test_placeholder",
}
],
},
"text2": {
"entries": [
{
"type": "placeholder",
"label": "test",
"content": "test_placeholder",
}
],
"overflow": "text1",
},
}
},
],
)
def test_schema_ticket_invalid(layout_context, layout):
style = TicketTestStyle()
schema = style.layout_schema(layout_context)
with pytest.raises(jsonschema.ValidationError):
jsonschema.validate(layout, schema)
def test_style_representation():
style = TicketTestStyle()
style_dict = style.asdict()
assert style_dict["platform"] == "test_platform"
assert style_dict["identifier"] == "test_ticket"
assert style_dict["name"] == _("Test Wallet Style Ticket")
assert style_dict["fieldgroups"][0]["identifier"] == "text1"
assert style_dict["fieldgroups"][0]["name"] == "Text 1"
assert style_dict["fieldgroups"][0]["content_type"] == "text"
assert style_dict["fieldgroups"][0]["labels"] == True
assert style_dict["fieldgroups"][0]["required"] == True
def test_layout_generate(layout_context):
style = TicketTestStyle()
layout = {
"fieldgroups": {
"text1": {
"entries": [
{
"type": "placeholder",
"label": {"de": "test-de", "en": "test-en"},
"content": "test_placeholder",
},
{"type": "text", "label": "test", "content": "test content"},
],
"overflow": "text2",
}
}
}
pass_layout = PassLayout(style, layout)
generated_pass = pass_layout.generate(layout_context)
assert (
generated_pass
== "Generated Pass: Test Wallet Style Ticket\n\nGroup: Text 1\ntest-en: test placeholder\ntest: test content\n\n"
)
@pytest.fixture
def pkpass_context():
key_pw = b"TESTPW"
now = datetime.datetime.now()
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
ca_cert = (
x509.CertificateBuilder()
.subject_name(
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "TTDR")])
)
.issuer_name(
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "ROOT Inc.")])
)
.public_key(ca_key.public_key())
.serial_number(1)
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=365))
.sign(ca_key, hashes.SHA256())
)
key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
cert = (
x509.CertificateBuilder()
.subject_name(
x509.Name(
[x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "UID=pass.test.test")]
)
)
.issuer_name(
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "TTDR")])
)
.public_key(key.public_key())
.serial_number(2)
.not_valid_before(now)
.not_valid_after(now + datetime.timedelta(days=365))
.sign(ca_key, hashes.SHA256())
)
ca_cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
key_pem = key.private_bytes(
encoding=serialization.Encoding.PEM,
format=serialization.PrivateFormat.TraditionalOpenSSL,
encryption_algorithm=serialization.BestAvailableEncryption(key_pw),
)
return {
"ca_certificate": ca_cert_pem,
"certificate": cert_pem,
"key": key_pem,
"password": key_pw,
}
def test_signed_zip(pkpass_context):
pkpass = SignedZipFile(**pkpass_context)
generated_pass = pkpass.finish()
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
assert set(zip_file.namelist()) == {"manifest.json", "signature"}
with zip_file.open("manifest.json") as f:
manifest = json.load(f)
assert manifest == {}
with zip_file.open("signature") as f:
signature = f.read()
assert signature
pkpass = SignedZipFile(**pkpass_context)
pkpass.add_file("test", b"test content")
generated_pass = pkpass.finish()
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
assert set(zip_file.namelist()) == {"test", "manifest.json", "signature"}
with zip_file.open("manifest.json") as f:
manifest = json.load(f)
assert manifest == {"test": "1eebdf4fdc9fc7bf283031b93f9aef3338de9052"}
with zip_file.open("signature") as f:
signature = f.read()
assert signature
pkpass = SignedZipFile(**pkpass_context)
pkpass.add_file("test/test", "test content")
generated_pass = pkpass.finish()
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
assert set(zip_file.namelist()) == {"test/test", "manifest.json", "signature"}
with zip_file.open("manifest.json") as f:
manifest = json.load(f)
assert manifest == {"test/test": "1eebdf4fdc9fc7bf283031b93f9aef3338de9052"}
with zip_file.open("signature") as f:
signature = f.read()
assert signature
def test_stringresource_minimal():
resource = StringResource(locales=["de", "en"])
resource.add_entry("TEST", LazyI18nString({"de": "test-de", "en": "test-en"}))
stringfiles = resource.generate()
assert stringfiles.keys() == {"de", "en"}
assert stringfiles["de"] == '"TEST" = "test-de";'
assert stringfiles["en"] == '"TEST" = "test-en";'
@pytest.mark.parametrize(
"input,output",
[
['te"st', 'te\\"st'],
["te\rst", "te\\rst"],
["te\nst", "te\\nst"],
["te\r\nst", "te\\r\\nst"],
["te\r\nst", "te\\r\\nst"],
["te\\st", "te\\\\st"],
],
)
def test_stringresource_escaping(input, output):
resource = StringResource(locales=["en"])
resource.add_entry("TEST", LazyI18nString({"en": input}))
stringfiles = resource.generate()
assert stringfiles.keys() == {"en"}
assert stringfiles["en"] == f'"TEST" = "{output}";'
resource = StringResource(locales=["en"])
resource.add_entry(input, LazyI18nString({"en": "test"}))
stringfiles = resource.generate()
assert stringfiles.keys() == {"en"}
assert stringfiles["en"] == f'"{output}" = "test";'
def test_stringresource_additional_locale():
resource = StringResource(locales=["de", "en", "fr"])
resource.add_entry("TEST", LazyI18nString({"de": "test-de", "en": "test-en"}))
stringfiles = resource.generate()
assert stringfiles.keys() == {"de", "en", "fr"}
assert stringfiles["de"] == '"TEST" = "test-de";'
assert stringfiles["en"] == '"TEST" = "test-en";'
assert stringfiles["fr"] == '"TEST" = "test-en";'