diff --git a/src/pretix/plugins/wallet/api.py b/src/pretix/plugins/wallet/api.py
index 568970f3c8..895c13ab40 100644
--- a/src/pretix/plugins/wallet/api.py
+++ b/src/pretix/plugins/wallet/api.py
@@ -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
diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/i18ninput.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/i18ninput.vue
index cc98f6c00e..1115fdebe8 100644
--- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/i18ninput.vue
+++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/i18ninput.vue
@@ -1,15 +1,15 @@
- 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 }}
diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue
index be41e492d7..b84dee7984 100644
--- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue
+++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/input/select.vue
@@ -6,7 +6,7 @@ defineOptions({
})
const props = defineProps<{
- label: string
+ label?: string
choices: Array<[string, string]>
errors?: string[]
}>()
@@ -24,7 +24,7 @@ watchEffect(() => {
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 }}
diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/placeholder-field-settings.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/placeholder-field-settings.vue
index 4b0c3de472..97941bd3df 100644
--- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/placeholder-field-settings.vue
+++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/placeholder-field-settings.vue
@@ -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;
@@ -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")
-
+
\ No newline at end of file
diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/text-content.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/text-content.vue
index defc8b6a7f..19004d7247 100644
--- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/text-content.vue
+++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/text-content.vue
@@ -1,12 +1,13 @@
- 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")
diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts b/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts
index 4bf28557c6..e5d1590de8 100644
--- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts
+++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/index.d.ts
@@ -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
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;
diff --git a/src/pretix/plugins/wallet/styles/apple.py b/src/pretix/plugins/wallet/styles/apple.py
index 372032f63a..20ce2fc458 100644
--- a/src/pretix/plugins/wallet/styles/apple.py
+++ b/src/pretix/plugins/wallet/styles/apple.py
@@ -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"
-
-
diff --git a/src/pretix/plugins/wallet/styles/base.py b/src/pretix/plugins/wallet/styles/base.py
index 5063e4f617..ff8e05358d 100644
--- a/src/pretix/plugins/wallet/styles/base.py
+++ b/src/pretix/plugins/wallet/styles/base.py
@@ -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)
\ No newline at end of file
diff --git a/src/pretix/plugins/wallet/views.py b/src/pretix/plugins/wallet/views.py
index eaf54de7c4..5e40a2c311 100644
--- a/src/pretix/plugins/wallet/views.py
+++ b/src/pretix/plugins/wallet/views.py
@@ -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
diff --git a/src/tests/plugins/wallet/test_wallet.py b/src/tests/plugins/wallet/test_wallet.py
new file mode 100644
index 0000000000..fa27e90ce5
--- /dev/null
+++ b/src/tests/plugins/wallet/test_wallet.py
@@ -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"
{field['content']}"
+ 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";'
+