mirror of
https://github.com/pretix/pretix.git
synced 2026-05-19 17:34: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"]]
|
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
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 }}
|
||||||
|
|||||||
@@ -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
|
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>
|
||||||
@@ -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>
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -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)
|
||||||
@@ -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
|
||||||
|
|||||||
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