mirror of
https://github.com/pretix/pretix.git
synced 2026-05-18 17:24:03 +00:00
WIP: i18n editor, start apple wallet generation
This commit is contained in:
@@ -49,7 +49,7 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
|
||||
style = platform_styles[data["style"]]
|
||||
|
||||
layout = PassLayout(style=style, layout=data["layout"])
|
||||
context = {"placeholders": {k: list(v.keys()) for k,v in get_layout_variables(self.context['event']).items()}}
|
||||
context = {"placeholders": {k: {"content": v['content']} for k,v in get_layout_variables(self.context['event']).items()}}
|
||||
layout.validate(context=context)
|
||||
return data
|
||||
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
<script setup lang="ts">
|
||||
import { useId, watchEffect } from 'vue'
|
||||
import { watchEffect } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string,
|
||||
errors?: string[],
|
||||
locales: Record<string, string>
|
||||
}>()
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<Record<string, string> | string>();
|
||||
watchEffect(() => {
|
||||
if (typeof modelValue.value === "string") {
|
||||
@@ -17,12 +17,9 @@ watchEffect(() => {
|
||||
modelValue.value = Object.fromEntries(Object.keys(props.locales).map((x): [string, string] => [x, oldVal]))
|
||||
}
|
||||
})
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
label.control-label(:for="id", v-if="props.label") {{ props.label }}
|
||||
.i18n-form-group
|
||||
input.form-control(v-for="(human_readable, locale) in locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
|
||||
input.form-control(v-for="(human_readable, locale) in locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
|
||||
@@ -6,7 +6,7 @@ defineOptions({
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label: string
|
||||
label?: string
|
||||
choices: Array<[string, string]>
|
||||
errors?: string[]
|
||||
}>()
|
||||
@@ -24,7 +24,7 @@ watchEffect(() => {
|
||||
|
||||
<template lang="pug">
|
||||
template(v-if="choices.length >= 1")
|
||||
label.control-label(:for="id") {{ props.label }}
|
||||
label.control-label(v-if="props.label" :for="id") {{ props.label }}
|
||||
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
|
||||
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
|
||||
@@ -8,7 +8,7 @@ import TextContent from "./text-content.vue";
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: FieldGroupDefinition;
|
||||
fieldgroup: PlaceholderFieldGroupDefinition;
|
||||
overflows: FieldGroupDefinition[];
|
||||
variables: Variables;
|
||||
locales: Record<string, string>;
|
||||
@@ -48,26 +48,33 @@ watchEffect(() => {
|
||||
.form-group()
|
||||
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
|
||||
h4 {{ gettext("Content") }}
|
||||
.row.form-group(v-for="n in fieldConfig.entries.length")
|
||||
.col-md-5(v-if="fieldgroup.labels")
|
||||
I18nInput(:label="gettext('Label')" v-model="fieldConfig.entries[n-1].label" :locales="locales")
|
||||
div(:class="'col-md-' + (fieldgroup.labels ? '6' : '11')")
|
||||
TextContent(v-if='fieldgroup.content_type == "text"'
|
||||
v-model="fieldConfig.entries[n-1]"
|
||||
:variables="props.variables")
|
||||
Select(:label="gettext('Content')"
|
||||
v-else-if='fieldgroup.content_type == "image"'
|
||||
v-model="fieldConfig.entries[n-1].content"
|
||||
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
|
||||
)
|
||||
.col-md-1
|
||||
label.control-label
|
||||
span.sr-only {{ gettext('Delete')}}
|
||||
button.btn.btn-danger(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
|
||||
i.fa.fa-trash
|
||||
span.sr-only {{ gettext('Delete')}}
|
||||
table.table.table-hover
|
||||
thead
|
||||
tr
|
||||
th.col-md-5(v-if="fieldgroup.labels") {{ gettext('Label') }}
|
||||
th(:class="'col-md-' + (fieldgroup.labels ? '6' : '11')") {{ gettext('Content') }}
|
||||
th.col-xs-1
|
||||
tbody
|
||||
tr(v-for="n,i in fieldConfig.entries.length" :key="i")
|
||||
td(v-if="fieldgroup.labels")
|
||||
.i18n-form-group
|
||||
I18nInput(v-model="fieldConfig.entries[n-1].label" :locales="locales")
|
||||
td
|
||||
TextContent(v-if='fieldgroup.content_type == "text"'
|
||||
v-model="fieldConfig.entries[n-1]"
|
||||
:variables="props.variables"
|
||||
:locales="locales")
|
||||
Select(v-else-if='fieldgroup.content_type == "image"'
|
||||
v-model="fieldConfig.entries[n-1].content"
|
||||
:choices="Object.entries(props.variables).map(([k,v]) => [k, v.label])"
|
||||
)
|
||||
td.text-right
|
||||
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n-1, 1)")
|
||||
i.fa.fa-trash
|
||||
span.sr-only {{ gettext('Delete')}}
|
||||
|
||||
button.btn.btn-default(type="button" @click="addVariable")
|
||||
i.fa.fa-plus
|
||||
span.sr-only {{ gettext("Add field") }}
|
||||
| {{ gettext("Add field") }}
|
||||
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
|
||||
</template>
|
||||
</template>
|
||||
@@ -1,12 +1,13 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, reactive } from 'vue'
|
||||
import Select from './input/select.vue'
|
||||
import Input from './input/input.vue'
|
||||
import I18nInput from './input/i18ninput.vue'
|
||||
|
||||
const gettext = (window as any).gettext
|
||||
|
||||
const props = defineProps<{
|
||||
variables: Variables
|
||||
locales: Record<string, string>;
|
||||
}>()
|
||||
const entry = defineModel<FieldEntry>({ required: true })
|
||||
|
||||
@@ -29,7 +30,7 @@ const selection = computed({
|
||||
set(newValue) {
|
||||
if (newValue == "other") {
|
||||
entry.value.type = "text"
|
||||
entry.value.content = "";
|
||||
entry.value.content = {};
|
||||
} else {
|
||||
entry.value.type = "placeholder"
|
||||
entry.value.content = newValue
|
||||
@@ -55,9 +56,10 @@ const textContent = computed({
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
Select(:label="gettext('Content')"
|
||||
v-model="selection"
|
||||
:choices="selectChoices"
|
||||
)
|
||||
Input(v-model="textContent" v-if="selection === 'other'")
|
||||
.i18n-form-group
|
||||
Select(
|
||||
v-model="selection"
|
||||
:choices="selectChoices"
|
||||
)
|
||||
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="locales")
|
||||
</template>
|
||||
|
||||
@@ -5,7 +5,7 @@ type BaseFieldGroupDefinition = {
|
||||
required: boolean;
|
||||
}
|
||||
|
||||
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroup;
|
||||
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroupDefinition;
|
||||
|
||||
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: 'placeholder';
|
||||
@@ -24,13 +24,19 @@ type I18nString = string | Record<string, string>
|
||||
|
||||
type FieldContentType = 'text' | 'image';
|
||||
|
||||
type FieldEntry = {
|
||||
type: 'placeholder' | FieldContentType;
|
||||
type PlaceholderFieldEntry = {
|
||||
type: 'placeholder';
|
||||
label?: I18nString;
|
||||
content?: string;
|
||||
}
|
||||
|
||||
type ContentFieldEntry = {
|
||||
type: FieldContentType;
|
||||
label?: I18nString;
|
||||
content?: I18nString;
|
||||
}
|
||||
|
||||
type FieldEntry = PlaceholderFieldEntry | ContentFieldEntry;
|
||||
|
||||
type Style = {
|
||||
identifier: string;
|
||||
|
||||
@@ -1,25 +1,132 @@
|
||||
from .base import (
|
||||
FieldEntry,
|
||||
FieldEntryContentType,
|
||||
FieldEntryType,
|
||||
FieldContentType,
|
||||
ImageFieldGroup,
|
||||
PlaceholderFieldGroup,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
CustomFieldEntry,
|
||||
)
|
||||
from django.utils.translation import gettext as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
import io
|
||||
import hashlib
|
||||
import zipfile
|
||||
import cryptography
|
||||
import cryptography.hazmat.primitives.serialization.pkcs7
|
||||
# from cryptography import x509
|
||||
# from cryptography.hazmat.primitives import hashes, serialization
|
||||
# from cryptography.hazmat.primitives.serialization import pkcs7
|
||||
import json
|
||||
|
||||
class ApplePlatform(WalletPlatform):
|
||||
identifier = "apple"
|
||||
name = _("Apple")
|
||||
|
||||
|
||||
class StringResource:
|
||||
# mapping string in default event locale -> LazyI18nString
|
||||
entries: dict[str, LazyI18nString]
|
||||
locales: set[str]
|
||||
|
||||
def __init__(self, locales):
|
||||
self.entries = {}
|
||||
self.locales = set(locales)
|
||||
|
||||
def add_entry(self, key: str, value: LazyI18nString): # TODO: replace LazyI18nString with dict or handle strings where data == ""
|
||||
if key in self.entries:
|
||||
raise ValueError(f"{key} already exists in this StringResource")
|
||||
self.entries[key] = value
|
||||
if isinstance(value.data, dict):
|
||||
self.locales |= value.data.keys()
|
||||
|
||||
def escape(self, string):
|
||||
return string.translate(str.maketrans({"\"": "\\\"", "\r": "\\r", "\n": "\\n", "\\": "\\\\"}))
|
||||
def generate_resource(self, language):
|
||||
output = ""
|
||||
for key, entry in self.entries.items():
|
||||
output += f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
|
||||
return output.strip()
|
||||
|
||||
def generate(self):
|
||||
return {language: self.generate_resource(language) for language in self.locales}
|
||||
|
||||
|
||||
|
||||
class SignedZipFile:
|
||||
""" Generates a zip-file with manifest and signature as apple expects a pkpass file to be """
|
||||
def __init__(self, ca_certificate, certificate, key, password):
|
||||
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(ca_certificate)
|
||||
self.certificate = cryptography.x509.load_pem_x509_certificate(certificate)
|
||||
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(key, password)
|
||||
self.password = password
|
||||
|
||||
self.file = io.BytesIO()
|
||||
self.zip_file = zipfile.ZipFile(self.file, "w")
|
||||
self.manifest = {}
|
||||
|
||||
def sign(self, data: bytes):
|
||||
return (
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
|
||||
.set_data(data)
|
||||
.add_signer(
|
||||
self.certificate,
|
||||
self.key,
|
||||
cryptography.hazmat.primitives.hashes.SHA256(),
|
||||
)
|
||||
.add_certificate(self.ca_certificate)
|
||||
.sign(
|
||||
cryptography.hazmat.primitives.serialization.Encoding.DER,
|
||||
[
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def finish(self):
|
||||
manifest = json.dumps(self.manifest).encode()
|
||||
signature = self.sign(manifest)
|
||||
self.add_file("manifest.json", manifest)
|
||||
self.add_file("signature", signature)
|
||||
self.zip_file.close()
|
||||
return self.file.getvalue()
|
||||
|
||||
def add_file(self, filename: str, content: str | bytes):
|
||||
if isinstance(content, str):
|
||||
content = content.encode()
|
||||
|
||||
with self.zip_file.open(filename, "w") as f:
|
||||
f.write(content)
|
||||
self.manifest[filename] = hashlib.sha1(content).hexdigest()
|
||||
|
||||
|
||||
class AppleWalletStyle(PassStyle):
|
||||
platform = ApplePlatform
|
||||
|
||||
def generate_pass_json(self, layout, context):
|
||||
pass_json = {}
|
||||
return pass_json
|
||||
|
||||
def generate(self, layout, context):
|
||||
for key in ["certificate", "key", "wwdr_certificate", "password"]:
|
||||
if key not in context:
|
||||
raise ValueError(f"{key} missing from context")
|
||||
pkpass = SignedZipFile(
|
||||
context["certificate"],
|
||||
context["key"],
|
||||
context["wwdr_certificate"],
|
||||
context["password"],
|
||||
)
|
||||
|
||||
pass_json = self.generate_pass_json()
|
||||
pkpass.add_file("pass.json", json.dumps(pass_json))
|
||||
return pkpass.finish()
|
||||
|
||||
|
||||
class AppleWalletEventTicket(AppleWalletStyle):
|
||||
identifier = "event_1"
|
||||
name = _("Event Ticket Layout 1")
|
||||
@@ -31,10 +138,8 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
max_entries=1,
|
||||
labels=False,
|
||||
default_entries=[
|
||||
FieldEntry(
|
||||
type=FieldEntryContentType.IMAGE,
|
||||
label=LazyI18nString("logo"),
|
||||
content="event:image",
|
||||
PlaceholderFieldEntry(
|
||||
content="poweredby",
|
||||
)
|
||||
],
|
||||
),
|
||||
@@ -44,13 +149,12 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
min_entries=1,
|
||||
max_entries=1,
|
||||
default_entries=[
|
||||
FieldEntry(
|
||||
type=FieldEntryContentType.PLACEHOLDER,
|
||||
PlaceholderFieldEntry(
|
||||
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
|
||||
content="item",
|
||||
)
|
||||
], # TODO: support Lazyi18nproxy here
|
||||
description=_("These fields appear prominently featured on the pass.")
|
||||
description=_("These fields appear prominently featured on the pass."),
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="secondary", name=_("Secondary"), max_entries=4
|
||||
@@ -62,5 +166,3 @@ class AppleWalletEventTicket(AppleWalletStyle):
|
||||
TextFieldGroup(identifier="back", name=_("Back")),
|
||||
]
|
||||
# preview_image = "apple/event_ticket.svg"
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import enum
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import jsonschema
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
class WalletPlatform:
|
||||
identifier: str
|
||||
@@ -47,19 +48,19 @@ class FieldContentType(enum.Enum):
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class FieldEntryContentType(enum.Enum):
|
||||
class FieldEntryType(enum.Enum):
|
||||
IMAGE = "image"
|
||||
TEXT = "text"
|
||||
PLACEHOLDER = "placeholder"
|
||||
|
||||
|
||||
class FieldEntry:
|
||||
type: FieldEntryContentType
|
||||
class FieldEntry[T]:
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: str
|
||||
content: T
|
||||
|
||||
def __init__(
|
||||
self, type: FieldEntryContentType, label: LazyI18nString | None, content: str
|
||||
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
|
||||
):
|
||||
self.type = type
|
||||
self.label = label
|
||||
@@ -68,6 +69,27 @@ class FieldEntry:
|
||||
def asdict(self) -> dict:
|
||||
return {"type": self.type.value, "content": self.content, "label": self.label.data if self.label else None}
|
||||
|
||||
class PlaceholderFieldEntry(FieldEntry[str]):
|
||||
type = FieldEntryType.PLACEHOLDER
|
||||
label: LazyI18nString | None
|
||||
content: str
|
||||
|
||||
def __init__(
|
||||
self, content: str, label: LazyI18nString | None = None
|
||||
):
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
|
||||
class CustomFieldEntry(FieldEntry[LazyI18nString]):
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: LazyI18nString
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {"type": self.type.value, "content": self.content.data, "label": self.label.data if self.label else None}
|
||||
|
||||
|
||||
|
||||
class PredefinedFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PREDEFINED
|
||||
@@ -126,13 +148,13 @@ class PlaceholderFieldGroup(FieldGroup):
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: dict,
|
||||
):
|
||||
placeholders = context.get("placeholders", {}).get(self.content_type.value, [])
|
||||
placeholders = list(context.get("placeholders", {}).get(self.content_type.value, {}).keys())
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": self.entries_schema(placeholders=placeholders),
|
||||
"overflow": {
|
||||
"oneOf": [
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{
|
||||
"type": "string",
|
||||
@@ -158,7 +180,7 @@ class PlaceholderFieldGroup(FieldGroup):
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"oneOf": [
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
@@ -170,7 +192,7 @@ class PlaceholderFieldGroup(FieldGroup):
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": self.content_type.value},
|
||||
"content": {"type": "string"},
|
||||
"content": {"$ref": "#/$defs/I18nString"},
|
||||
}
|
||||
},
|
||||
],
|
||||
@@ -252,6 +274,8 @@ class PassStyle:
|
||||
|
||||
return schema
|
||||
|
||||
def generate(self, layout, context):
|
||||
raise NotImplementedError()
|
||||
|
||||
class PassLayout:
|
||||
style: PassStyle
|
||||
@@ -267,3 +291,7 @@ class PassLayout:
|
||||
jsonschema.validate(self.layout, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValidationError("Invalid layout: {}".format(str(e)))
|
||||
|
||||
def generate(self, context):
|
||||
self.validate(context)
|
||||
return self.style.generate(self.layout, context)
|
||||
@@ -26,6 +26,9 @@ def get_layout_variables(event):
|
||||
| {"poweredby": {"label": _("pretix-Logo")}}, # TODO: image upload
|
||||
}
|
||||
|
||||
def get_editor_variables(event):
|
||||
return {t: {vid: {"label": v.get("label"), "editor_sample": v.get("editor_sample")} for vid,v in vs.items()} for t,vs in get_layout_variables(event).items()}
|
||||
|
||||
|
||||
# TODO: should this even be a list view?
|
||||
class LayoutListView(EventPermissionRequiredMixin, ListView):
|
||||
@@ -61,7 +64,7 @@ class LayoutEditorView(DetailView):
|
||||
context["styles"] = {
|
||||
style.identifier: style.asdict() for style in self.get_platform_styles()
|
||||
}
|
||||
context["variables"] = get_layout_variables(self.request.event)
|
||||
context["variables"] = get_editor_variables(self.request.event)
|
||||
context['locales'] = {l: dict(settings.LANGUAGES).get(l, l) for l in self.request.event.settings.get('locales')}
|
||||
|
||||
return context
|
||||
|
||||
482
src/tests/plugins/wallet/test_wallet.py
Normal file
482
src/tests/plugins/wallet/test_wallet.py
Normal 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";'
|
||||
|
||||
Reference in New Issue
Block a user