Compare commits

...

10 Commits

Author SHA1 Message Date
Kara Engelhardt
f6f4c1c56c WIP 2026-05-18 17:27:23 +02:00
Kara Engelhardt
9064069cf3 WIP: i18n editor, start apple wallet generation 2026-05-18 17:27:23 +02:00
Kara Engelhardt
c48d30919f WIP: i18nfields, refactoring, jsonschema-validatoin 2026-05-18 17:27:23 +02:00
Kara Engelhardt
30b64546a7 WIP: use api 2026-05-18 17:27:23 +02:00
Kara Engelhardt
477b1e42d4 WIP 2026-05-18 17:27:23 +02:00
Kara Engelhardt
66882eb115 WIP 2026-05-18 17:27:23 +02:00
Kara Engelhardt
2e7d54174d WIP 2026-05-18 17:27:23 +02:00
Kara Engelhardt
a521956aca WIP 2026-05-18 17:27:23 +02:00
Kara Engelhardt
cfcd0f4206 WIP 2026-05-18 17:27:23 +02:00
Kara Engelhardt
affb32c513 Add wallet plugins stub 2026-05-18 17:27:23 +02:00
39 changed files with 2339 additions and 7 deletions

View File

@@ -23,7 +23,7 @@
"build": "npm run build:control -s && npm run build:widget -s",
"build:control": "vite build",
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin src/pretix/plugins/wallet",
"test": "echo \"Error: no test specified\" && exit 1"
},
"dependencies": {

View File

@@ -32,6 +32,7 @@ ignore =
src/tests/plugins/stripe/*
src/tests/plugins/sendmail/*
src/tests/plugins/ticketoutputpdf/*
src/tests/plugins/wallet/*
.*
CODE_OF_CONDUCT.md
CONTRIBUTING.md

View File

@@ -66,6 +66,7 @@ INSTALLED_APPS = [
'pretix.plugins.returnurl',
'pretix.plugins.autocheckin',
'pretix.plugins.webcheckin',
'pretix.plugins.wallet',
'django_countries',
'oauth2_provider',
'phonenumber_field',

View File

@@ -114,7 +114,7 @@ class BaseTicketOutput:
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
If you want, you can just iterate over ``self.get_tickets_to_print`` which applies the
appropriate filters for you.
"""
with tempfile.TemporaryDirectory() as d:
@@ -192,6 +192,17 @@ class BaseTicketOutput:
"""
pass
@property
def is_meta(self) -> bool:
"""
Returns whether or whether not this output is a "meta" output that only works as a settings holder
and should never be used directly. This is a trick to implement outputs with multiple formats but
unified settings.
.. note:: You should set is_enabled to False for meta outputs.
"""
return False
@property
def download_button_text(self) -> str:
"""

View File

@@ -945,7 +945,7 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
class ProviderForm(SettingsForm):
"""
This is a SettingsForm, but if fields are set to required=True, validation
errors are only raised if the payment method is enabled.
errors are only raised if the provider is enabled.
"""
def __init__(self, *args, **kwargs):

View File

@@ -965,7 +965,7 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.identifier == self.kwargs.get('output'):
if provider.identifier == self.kwargs.get('output') and not provider.is_meta:
return provider
def get(self, request, *args, **kwargs):
@@ -1068,6 +1068,11 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
provider_settings_fields = provider.settings_form_fields
provider_settings_content = provider.settings_content_render(self.request)
if not provider_settings_fields and not provider_settings_content:
continue
provider.form = ProviderForm(
obj=self.request.event,
settingspref='ticketoutput_%s_' % provider.identifier,
@@ -1077,17 +1082,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
provider.form.fields = OrderedDict(
[
('ticketoutput_%s_%s' % (provider.identifier, k), v)
for k, v in provider.settings_form_fields.items()
for k, v in provider_settings_fields.items()
]
)
provider.settings_content = provider.settings_content_render(self.request)
provider.settings_content = provider_settings_content
provider.form.prepare_fields()
provider.evaluated_preview_allowed = True
if not provider.preview_allowed:
provider.evaluated_preview_allowed = False
else:
for k, v in provider.settings_form_fields.items():
for k, v in provider_settings_fields.items():
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
provider.evaluated_preview_allowed = False
break

View File

@@ -564,6 +564,8 @@ class OrderDetail(OrderView):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
provider = response(self.request.event)
if provider.is_meta:
continue
buttons.append({
'text': provider.download_button_text or 'Ticket',
'icon': provider.download_button_icon or 'fa-download',

View File

@@ -0,0 +1,21 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#

View File

@@ -0,0 +1,84 @@
from rest_framework import viewsets
from django.db import transaction
from .styles import PassLayout, AVAILABLE_STYLES_DICT
from .models import WalletLayout
from pretix.api.serializers.i18n import I18nAwareModelSerializer
import django_filters.rest_framework
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from .views import get_layout_variables
class WalletLayoutSerializer(I18nAwareModelSerializer):
class Meta:
model = WalletLayout
fields = ("id", "platform", "name", "style", "layout")
read_only_fields = ("id",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if self.instance:
self.fields['platform'].read_only = True
def save(self, *args, **kwargs):
super().save(*args, **kwargs, event=self.context["event"])
def validate_platform(self, value):
if self.instance and value != self.instance.platform:
raise ValidationError(_("Platform cannot be changed"))
if value not in AVAILABLE_STYLES_DICT:
raise ValidationError(_("Invalid platform"))
return value
def validate_layout(self, value):
if not isinstance(value, dict):
raise ValidationError(_("Layout must be a dict"))
return value
def validate(self, data):
if self.instance:
platform = self.instance.platform
else:
platform = data.get('platform', None)
if "style" in data and "layout" in data and platform:
platform_styles = AVAILABLE_STYLES_DICT[platform]
if data["style"] not in platform_styles:
raise ValidationError(_("Invalid style"))
style = platform_styles[data["style"]]
layout = PassLayout(style=style, layout=data["layout"])
context = {"placeholders": get_layout_variables(self.context['event'])}
layout.validate(context=context)
return data
class WalletLayoutViewSet(viewsets.ModelViewSet):
model = WalletLayout
queryset = WalletLayout.objects.none()
serializer_class = WalletLayoutSerializer
filter_backends = (django_filters.rest_framework.DjangoFilterBackend,)
filterset_fields = ["platform"]
permission = "event.settings.general:write"
def get_queryset(self):
return self.request.event.wallet_layouts.all()
def get_serializer(self, *args, **kwargs):
return super().get_serializer(*args, **kwargs)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx["event"] = self.request.event
return ctx
@transaction.atomic()
def perform_update(self, serializer):
super().perform_update(serializer)
serializer.instance.log_action(
action="pretix.plugins.wallet.layout.changed",
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)

View File

@@ -0,0 +1,41 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.apps import AppConfig
from django.utils.translation import gettext_lazy as _
from pretix import __version__ as version
class WalletApp(AppConfig):
name = 'pretix.plugins.wallet'
verbose_name = _("wallet")
class PretixPluginMeta:
name = _("wallet")
author = _("the pretix team")
version = version
category = 'FORMAT'
description = _("Issue wallet passes for tickets (e.g. apple wallet, google wallet)")
def ready(self):
from . import signals # NOQA

View File

@@ -0,0 +1,44 @@
# Generated by Django 4.2.28 on 2026-03-17 16:29
from django.db import migrations, models
import django.db.models.deletion
import pretix.base.models.base
class Migration(migrations.Migration):
initial = True
dependencies = [
("pretixbase", "0297_outgoingmail"),
]
operations = [
migrations.CreateModel(
name="WalletLayout",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("name", models.CharField(max_length=190)),
("platform", models.CharField(max_length=10)),
("style", models.CharField(max_length=255)),
("layout", models.TextField()),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="wallet_layouts",
to="pretixbase.event",
),
),
],
options={
"ordering": ("name",),
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]

View File

@@ -0,0 +1,63 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db import models
from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel
from django_scopes import ScopedManager
class WalletLayout(LoggedModel):
event = models.ForeignKey(
'pretixbase.Event',
on_delete=models.CASCADE,
related_name='wallet_layouts'
)
name = models.CharField(
max_length=190,
verbose_name=_('Name')
)
platform = models.CharField(max_length=10)
style = models.CharField(max_length=255)
layout = models.JSONField(default=dict)
objects = ScopedManager(organizer='event__organizer')
class Meta:
ordering = ("name",)
def __str__(self):
return self.name
class WalletLayoutItem(models.Model):
item = models.ForeignKey('pretixbase.Item', null=True, blank=True, related_name='walletlayout_assignments',
on_delete=models.CASCADE)
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
sales_channel = models.ForeignKey(
"pretixbase.SalesChannel",
on_delete=models.CASCADE,
)
class Meta:
unique_together = (('item', 'layout', 'sales_channel'),)
ordering = ("id",)

View File

View File

@@ -0,0 +1,35 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from pretix.base.signals import register_ticket_outputs
from .ticketoutput import OUTPUTS
def connect_signals():
for output in OUTPUTS:
# DIY functools.partial to make get_defining_app happy
def get_register_func(o):
def register(sender, **kwargs):
return o
return register
register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"output_{output.identifier}")
connect_signals()

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed, ref, watchEffect } from "vue";
import StyleSettings from "./style-settings.vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
const gettext = (window as any).gettext;
const isLoading = ref<boolean>(true);
const wallet_layout = ref<Layout | null>(null);
const STYLES: Styles = JSON.parse(
document.querySelector("#styles")?.textContent ?? "{}",
);
const VARIABLES: VariableConfig = JSON.parse(
document.querySelector("#variables")?.textContent ?? "{}",
);
const LOCALES: Record<string, string> = JSON.parse(
document.querySelector("#locales")?.textContent ?? "{}",
);
const CSRF_TOKEN =
document.querySelector<HTMLInputElement>("input[name=csrfmiddlewaretoken]")
?.value ?? "";
const props = defineProps<{
layoutId: string;
}>();
watchEffect(() => {
// TODO: error handling / proper api client
isLoading.value = true;
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
)
.then((x) => x.json())
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
});
function saveLayout(e: SubmitEvent) {
e.preventDefault();
isLoading.value = true;
// TODO: error handling / proper api client
fetch(
`/api/v1/organizers/demo/events/wallet/walletlayouts/${props.layoutId}/`,
{
method: "PUT",
headers: {
"content-type": "application/json",
"X-CSRFToken": CSRF_TOKEN,
},
body: JSON.stringify(wallet_layout.value),
},
)
.then((x) => x.json())
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
}
</script>
<template lang="pug">
// TODO: add :key for all `v-for`s
// TODO: i18n textfields
// TODO: proper spinner
template(v-if="isLoading") {{ gettext("Loading...") }}
form(v-else @submit="saveLayout")
.row
.col-md-8
.form-group()
Input(label="Name" v-model="wallet_layout.name")
.form-group()
Select(label="Style" v-model="wallet_layout.style" :choices="Object.values(STYLES).map(x => [x.identifier, x.name])")
StyleSettings(v-if="wallet_layout.style" v-model="wallet_layout.layout" :style="STYLES[wallet_layout.style]" :variables="VARIABLES" :locales="LOCALES")
.col-md-4
.panel.panel-default
.panel-heading Preview
.panel-body
// TODO: Preview
pre
code {{ wallet_layout }}
pre(v-if="wallet_layout.style")
code {{ STYLES[wallet_layout.style] }}
.form-group.submit-group
button.btn.btn-primary.btn-save(type="submit") Submit
</template>

View File

@@ -0,0 +1,25 @@
<script setup lang="ts">
import { watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
errors?: string[],
locales: Record<string, string>
}>();
const modelValue = defineModel<Record<string, string> | string>();
watchEffect(() => {
if (typeof modelValue.value === "string") {
const oldVal = modelValue.value;
modelValue.value = Object.fromEntries(Object.keys(props.locales).map((x): [string, string] => [x, oldVal]))
}
})
</script>
<template lang="pug">
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>

View File

@@ -0,0 +1,20 @@
<script setup lang="ts">
import { useId } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string,
errors?: string[],
}>()
const modelValue = defineModel<string|null>();
const id = useId()
</script>
<template lang="pug">
label.control-label(:for="id", v-if="props.label") {{ props.label }}
input.form-control(:id="id" v-model="modelValue" v-bind="$attrs")
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>

View File

@@ -0,0 +1,31 @@
<script setup lang="ts">
import { useId, watchEffect } from 'vue'
defineOptions({
inheritAttrs: false
})
const props = defineProps<{
label?: string
choices: Array<[string, string]>
errors?: string[]
}>()
const modelValue = defineModel<string|null>();
const id = useId()
watchEffect(() => {
if (props.choices.length === 1) {
modelValue.value = props.choices[0][0]
} else if (props.choices.length < 1) {
modelValue.value = null
}
})
</script>
<template lang="pug">
template(v-if="choices.length >= 1")
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 }}
</template>

View File

@@ -0,0 +1,80 @@
<script setup lang="ts">
import { computed, reactive, watchEffect } from "vue";
import Select from "./input/select.vue";
import Input from "./input/input.vue";
import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: PlaceholderFieldGroupDefinition;
overflows: FieldGroupDefinition[];
variables: Variables;
locales: Record<string, string>;
}>();
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({ required: true });
const overflowOptions = computed((): Array<[string | null, string]> => {
if (props.overflows.length) {
return [
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
[null, "Do not overflow"],
];
} else {
return [];
}
});
function addVariable() {
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
}
watchEffect(() => {
if (!fieldConfig.value) {
fieldConfig.value = {overflow: null, entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries))};
}
if (fieldConfig.value && !fieldConfig.value.entries) {
fieldConfig.value.entries = JSON.parse(JSON.stringify(props.fieldgroup.default_entries))
}
});
</script>
<template lang="pug">
.panel.panel-default
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig")
.form-group()
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
h4 {{ gettext("Content") }}
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
| {{ gettext("Add field") }}
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
</template>

View File

@@ -0,0 +1,18 @@
<script setup lang="ts">
const gettext = (window as any).gettext;
const props = defineProps<{
fieldgroup: FieldGroupDefinition;
}>();
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
</script>
<template lang="pug">
.panel.panel-default
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body
.form-group
span.text-muted These fields appear somewhere and are visible too.
</template>

View File

@@ -0,0 +1,41 @@
<script setup lang="ts">
import { computed, watchEffect } from "vue";
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
import PredefinedFieldSettings from "./predefined-field-settings.vue";
const gettext = (window as any).gettext;
const props = defineProps<{
variables: VariableConfig
style?: Style;
locales: Record<string, string>;
}>();
const layout = defineModel<LayoutData>();
watchEffect(() => {
if (layout.value === undefined) {
return
}
if (layout.value.fieldgroups === undefined) {
layout.value.fieldgroups = {};
}
});
</script>
<template lang="pug">
h2.h3 {{ gettext("Field Groups") }}
template(v-if="props.style && layout.fieldgroups"
v-for="(fieldgroup, fieldgroupId) in props.style.fieldgroups")
PlaceholderFieldSettings(
v-if="fieldgroup.type == 'placeholder'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup"
:overflows="props.style.fieldgroups.slice(fieldgroupId + 1).filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
:variables="variables[fieldgroup.content_type]"
:locales="locales"
)
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
v-model="layout.fieldgroups[fieldgroup.identifier]"
:fieldgroup="fieldgroup")
</template>

View File

@@ -0,0 +1,65 @@
<script setup lang="ts">
import { computed, reactive } from 'vue'
import Select from './input/select.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 })
const selectChoices = computed(() =>{
const choices = Object.entries(props.variables).map(([k,v]): [string, string] => [k, v.label])
choices.push(["other", gettext("Other…")])
return choices
});
const selection = computed({
get() {
if (entry.value.type === 'placeholder') {
return entry.value.content
} else if (entry.value.type === 'text') {
return "other"
} else {
throw new Error(`Unknown entry type "${entry.value.type}"`);
}
},
set(newValue) {
if (newValue == "other") {
entry.value.type = "text"
entry.value.content = {};
} else {
entry.value.type = "placeholder"
entry.value.content = newValue
}
}
})
const textContent = computed({
get() {
if (entry.value.type === 'placeholder') {
return ""
} else if (entry.value.type === 'text') {
return entry.value.content
} else {
throw new Error(`Unknown entry type "${entry.value.type}"`);
}
},
set(newValue) {
entry.value.content = newValue
}
})
</script>
<template lang="pug">
.i18n-form-group
Select(
v-model="selection"
:choices="selectChoices"
)
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="locales")
</template>

View File

@@ -0,0 +1,75 @@
type BaseFieldGroupDefinition = {
type: string;
identifier: string;
name: string;
required: boolean;
}
type FieldGroupDefinition = PlaceholderFieldGroupDefinition | PredefinedFieldGroupDefinition;
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
type: 'placeholder';
content_type: FieldContentType;
default_entries: FieldEntry[];
labels: boolean;
min_entries: number|null;
max_entries: number|null;
}
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
type: 'predefined';
}
type I18nString = string | Record<string, string>
type FieldContentType = 'text' | 'image';
type PlaceholderFieldEntry = {
type: 'placeholder';
label?: I18nString;
content?: string;
}
type ContentFieldEntry = {
type: FieldContentType;
label?: I18nString;
content?: I18nString;
}
type FieldEntry = PlaceholderFieldEntry | ContentFieldEntry;
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
};
type Variable = {
label: string
};
type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type PlaceholderFieldGroupConfig = {
entries: Array<FieldEntry>;
overflow: string | null;
};
type PredefinedFieldGroupConfig = {};
type FieldGroupConfig = PlaceholderFieldGroupConfig | PredefinedFieldGroupConfig;
type LayoutData = {
fieldgroups: Record<string, FieldGroupConfig>;
};
type Layout = {
name?: string;
style?: string;
layout?: LayoutData;
};

View File

@@ -0,0 +1,13 @@
import { createApp } from 'vue'
import App from './components/app.vue'
const mountEl = document.querySelector<HTMLElement>('#editor')!
const app = createApp(App, mountEl.dataset)
app.mount(mountEl)
app.config.errorHandler = (error, _vm, info) => {
// vue fatals on errors by default, which is a weird choice
// https://github.com/vuejs/core/issues/3525
// https://github.com/vuejs/router/discussions/2435
console.error('[VUE]', info, error)
}

View File

@@ -0,0 +1,17 @@
from .apple import ApplePlatform, AppleWalletEventTicket
from .google import GooglePlatform, GoogleWalletEventTicket
from .base import PassLayout
AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": GooglePlatform}
AVAILABLE_STYLES = {
"apple": [AppleWalletEventTicket()],
"google": [
GoogleWalletEventTicket()
],
}
AVAILABLE_STYLES_DICT = {
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
}
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"]

View File

@@ -0,0 +1,256 @@
from .base import (
FieldEntryType,
ImageFieldGroup,
PlaceholderFieldGroup,
PredefinedFieldGroup,
TextFieldGroup,
WalletPlatform,
PassStyle,
PlaceholderFieldEntry,
)
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
import json
from django.contrib.staticfiles import finders
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):
if key in self.entries:
raise ValueError(f"{key} already exists in this StringResource")
self.entries[key] = value
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 pass_content(self, layout, context, strings):
raise NotImplementedError()
def generate_pass_json(self, layout, context, strings):
def add_from_context(key):
value = context.get(key)
if not value:
raise ValueError(f"{key} must be set to a truthy value")
return value
pass_json = {
"formatVersion": 1,
"description": add_from_context("description"),
"organizationName": add_from_context("organizationName"),
"passTypeIdentifier": add_from_context("passTypeIdentifier"),
"teamIdentifier": add_from_context("teamIdentifier"),
"serialNumber": add_from_context("serialNumber"),
**self.pass_content(layout, context, strings),
}
return pass_json
def generate(self, layout, context):
for key in ["ca_certificate", "certificate", "key", "password", "locales"]:
if key not in context:
raise ValueError(f"{key} missing from context")
pkpass = SignedZipFile(
context["ca_certificate"],
context["certificate"],
context["key"],
context["password"],
)
strings = StringResource(locales=context['locales'])
pass_json = self.generate_pass_json(layout, context, strings)
print(pass_json)
pkpass.add_file(
"icon.png", open(finders.find("pretix_passbook/icon.png"), "rb").read()
)
pkpass.add_file("pass.json", json.dumps(pass_json))
return pkpass.finish()
class AppleWalletEventTicket(AppleWalletStyle):
identifier = "event_1"
name = _("Event Ticket Layout 1")
fieldgroups = [
ImageFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=0,
max_entries=1,
labels=False,
default_entries=[
PlaceholderFieldEntry(
content="poweredby",
)
],
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
content="item",
)
], # TODO: support Lazyi18nproxy here
description=_("These fields appear prominently featured on the pass."),
),
TextFieldGroup(
identifier="secondary", name=_("Secondary"), max_entries=4
), # TODO: validation of max field count if combined "Coupons, store cards, and generic passes with a square barcode can have a total of up to four secondary and auxiliary fields, combined."
TextFieldGroup(
identifier="headers", name=_("Header"), max_entries=3
), # TODO: header image
TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
TextFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"
def get_pass_fields(self, layout, context):
fields = {}
for group in self.fieldgroups:
if isinstance(group, PredefinedFieldGroup):
pass
elif isinstance(group, PlaceholderFieldGroup):
group_fields = []
if group.identifier in layout["fieldgroups"]:
for field in layout["fieldgroups"][group.identifier]["entries"]:
field_entry = {}
if group.labels:
field_entry["label"] = LazyI18nString(field["label"])
if field["type"] == FieldEntryType.PLACEHOLDER.value:
placeholder = (
context.get("placeholders")
.get(group.content_type.value, {})
.get(field["content"])
)
if placeholder:
placeholder_value = placeholder["evaluate"](
*context.get("evaluation_context", [])
)
if placeholder_value:
field_entry["value"] = placeholder_value
elif field["type"] == FieldEntryType.TEXT.value:
placeholder_value = LazyI18nString(field["content"])
elif field["type"] == FieldEntryType.IMAGE.value:
raise NotImplementedError(
"Image placeholders not implemented"
)
if "value" in field_entry and field_entry["value"]:
group_fields.append(field_entry)
if group.min_entries and len(group_fields) < group.min_entries:
raise ValueError(
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
)
fields[group.identifier] = group_fields[: group.max_entries]
else:
raise ValueError("Unknown field group")
return fields
def convert_fields(self, strings, fields):
converted = []
for i,f in enumerate(fields):
converted_field = {**f, "key": f"primary-{i}"}
if "label" in converted_field and isinstance(converted_field['label'], LazyI18nString):
strings.add_entry(f"primary-{i}-label", converted_field['label'])
converted_field['label'] = f"primary-{i}-label"
converted.append(converted_field)
return converted
def pass_content(self, layout, context, strings):
fields = self.get_pass_fields(layout, context)
return {
"eventTicket": {
"primaryFields": self.convert_fields(strings, fields['primary'])
}
}

View File

@@ -0,0 +1,298 @@
import enum
from i18nfield.strings import LazyI18nString
import jsonschema
from django.core.exceptions import ValidationError
class WalletPlatform:
identifier: str
name: str
class FieldGroupType(enum.Enum):
PLACEHOLDER = "placeholder"
PREDEFINED = "predefined"
class FieldGroup:
type: FieldGroupType
identifier: str
name: str
description: str
required: bool = False
def __init__(self, identifier: str, name: str, description=None, required=False):
self.identifier = identifier
self.name = name
self.required = required
self.description = description or ""
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
) -> dict:
raise NotImplemented()
def asdict(self):
return {
"type": self.type.value,
"identifier": self.identifier,
"name": self.name,
"description": self.description,
"required": self.required,
}
class FieldContentType(enum.Enum):
IMAGE = "image"
TEXT = "text"
class FieldEntryType(enum.Enum):
IMAGE = "image"
TEXT = "text"
PLACEHOLDER = "placeholder"
class FieldEntry[T]:
type: FieldEntryType
label: LazyI18nString | None
content: T
def __init__(
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
):
self.type = type
self.label = label
self.content = content
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
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
):
return {
"type": "object"
}
class PlaceholderFieldGroup(FieldGroup):
type = FieldGroupType.PLACEHOLDER
content_type: FieldContentType
default_entries: list[FieldEntry]
labels: bool
min_entries: int | None
max_entries: int | None
def __init__(
self,
identifier: str,
name: str,
content_type: FieldContentType,
description: str=None,
required=False,
default_entries=None,
min_entries=None,
max_entries=None,
labels=True,
):
super().__init__(identifier, name, description, required)
self.content_type = content_type
self.default_entries = default_entries or []
self.min_entries = min_entries
self.max_entries = max_entries
self.labels = labels
if self.required and (self.min_entries is None or self.min_entries < 1):
self.min_entries = 1
def asdict(self):
return {
**super().asdict(),
"content_type": self.content_type.value,
"default_entries": [x.asdict() for x in self.default_entries],
"labels": self.labels,
"min_entries": self.min_entries,
"max_entries": self.max_entries,
}
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: dict,
):
placeholders = list(context.get("placeholders", {}).get(self.content_type.value, {}).keys())
return {
"type": "object",
"properties": {
"entries": self.entries_schema(placeholders=placeholders),
"overflow": {
"anyOf": [
{"type": "null"},
{
"type": "string",
"enum": [
f.identifier
for f in remaining_fields
if isinstance(f, PlaceholderFieldGroup)
and f.content_type == self.content_type
],
},
]
},
},
"required": ["entries"],
}
def entries_schema(self, placeholders: list[str]):
baseprops = {}
if self.labels:
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
schema = {
"type": "array",
"items": {
"type": "object",
"anyOf": [
{
"properties": {
**baseprops,
"type": {"const": "placeholder"},
"content": {"enum": placeholders},
}
},
{
"properties": {
**baseprops,
"type": {"const": self.content_type.value},
"content": {"$ref": "#/$defs/I18nString"},
}
},
],
"required": ["type", "content"],
},
}
if self.labels:
schema["items"]["required"].append("label")
if self.min_entries is not None:
schema["minItems"] = self.min_entries
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
return schema
class TextFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.TEXT
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, **kwargs)
class ImageFieldGroup(PlaceholderFieldGroup):
content_type = FieldContentType.IMAGE
def __init__(self, **kwargs):
super().__init__(content_type=self.content_type, **kwargs)
class PassStyle:
platform: type[WalletPlatform]
identifier: str # unique within platform
name: str
# order here limits in what order users can configure field "overspilling" (if too many fields are defined, where should the rest go) -> can only go down in the list
# we evaluate the fields in this order, so they overspill in this order as well (fields from primary are appended to the overspilling field before fields from secondary are etc)
fieldgroups: list[FieldGroup]
def asdict(self):
return {
"platform": self.platform.identifier,
"identifier": self.identifier,
"name": self.name,
"fieldgroups": [x.asdict() for x in self.fieldgroups],
}
def layout_schema(self, context):
schema = {
"$schema": "https://json-schema.org/draft/2020-12/schema",
# TODO: $id
"title": self.name,
"type": "object",
"properties": {
"fieldgroups": {
"description": "Layout Field Groups",
"type": "object",
"properties": {
group.identifier: group.layout_schema(
context=context, remaining_fields=self.fieldgroups[i:]
)
for (i, group) in enumerate(self.fieldgroups)
},
"required": [
group.identifier for group in self.fieldgroups if group.required
],
}
},
"$defs": {
"I18nString": {
"oneOf": [
{"type": "string"},
{"type": "object", "additionalProperties": {"type": "string"}},
]
}
},
}
if any(group.required for group in self.fieldgroups):
schema["required"] = ["fieldgroups"]
return schema
def generate(self, layout, context):
raise NotImplementedError()
class PassLayout:
style: PassStyle
layout: dict
def __init__(self, style, layout):
self.style = style
self.layout = layout
def validate(self, context):
schema = self.style.layout_schema(context)
try:
jsonschema.validate(self.layout, schema)
except jsonschema.ValidationError as e:
raise ValidationError("Invalid layout: {}".format(str(e)))
def generate(self, context):
# TODO: how to handle nonexisting placeholders here?
self.validate(context)
return self.style.generate(self.layout, context)

View File

@@ -0,0 +1,20 @@
from .base import PassStyle, PredefinedFieldGroup, TextFieldGroup, WalletPlatform
from django.utils.translation import gettext_lazy as _
class GooglePlatform(WalletPlatform):
identifier = "google"
name = _("Google")
class GoogleWalletStyle(PassStyle):
platform = GooglePlatform
class GoogleWalletEventTicket(PassStyle):
identifier = "event"
name = "Event Ticket"
platform = GooglePlatform
fieldgroups = [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
TextFieldGroup(identifier="qrcode", name=_("QR-Code"), labels=False),
]

View File

@@ -0,0 +1,35 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load vite %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "New layout" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">
{% trans "Ticket design" %}
</label>
<div class="col-md-9 form-control-static">
<p>
{% blocktrans trimmed %}
You can modify the design after you saved this page.
{% endblocktrans %}
</p>
</div>
</div>
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -0,0 +1,21 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load bootstrap3 %}
{% load vite %}
{% load static %}
{% load compress %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "Edit layout" %} {{ object.name }} </h1>
{{ styles|json_script:"styles" }}
{{ variables|json_script:"variables" }}
{{ locales|json_script:"locales" }}
<div id="editor" data-layout-id="{{ object.pk }}"></div>
{% vite_hmr %}
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
{% csrf_token %}
{% endblock %}

View File

@@ -0,0 +1,83 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% load wallet %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "Wallet layouts" %}</h1>
<div class="tabbed-form">
{% for platform in platforms.values %}
<fieldset>
<legend>{{platform.name}}</legend>
{% with platform_layouts=platform|platform_layouts:request.event %}
{% if platform_layouts|length == 0 %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't created any layouts yet.
{% endblocktrans %}
</p>
{% if "event.settings.general:write" in request.eventpermset %}
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug platform=platform.identifier %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new layout" %}
</a>
{% endif %}
</div>
{% else %}
<div class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
<th>{% trans "Name" %}</th>
<th>{% trans "Default" %}</th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody>
{% for l in platform_layouts %}
<tr>
<td>
{% if "can_change_event_settings" in request.eventpermset %}
<strong><a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
{{ l.name }}
</a></strong>
{% else %}
<strong>{{ l.name }}</strong>
{% endif %}
</td>
<td>
{% if l.default %}
<span class="text-success">
<span class="fa fa-check"></span>
{% trans "Default" %}
</span>
{% elif "can_change_event_settings" in request.eventpermset %}
<form class="form-inline" method="post"
action="{% url "plugins:wallet:default" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
{% csrf_token %}
<button class="btn btn-default btn-sm">
{% trans "Make default" %}
</button>
</form>
{% endif %}
</td>
<td class="text-right flip">
{% if "can_change_event_settings" in request.eventpermset %}
<a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug platform=platform.identifier %}?copy_from={{ l.id }}"
class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
<a href="{% url "plugins:wallet:delete" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
{% endif %}
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
{% endwith %}
</fieldset>
{% endfor %}
</div>
{% endblock %}

View File

@@ -0,0 +1,8 @@
{% load i18n %}
<p>
<a class="btn btn-primary btn-lg" target="_blank"
href="{% url "plugins:wallet:index" organizer=request.organizer.slug event=request.event.slug %}">
<span class="fa fa-paint-brush"></span>
{% trans "Edit layouts" %}
</a>
</p>

View File

@@ -0,0 +1,10 @@
from django import template
from ..models import WalletLayout
register = template.Library()
@register.filter
def platform_layouts(platform, event):
return WalletLayout.objects.filter(event=event, platform=platform.identifier)

View File

@@ -0,0 +1,131 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from django.utils.translation import gettext_lazy as _
from pretix.base.ticketoutput import BaseTicketOutput
from pretix.base.models import Event
from pretix.base.settings import SettingsSandbox
from django.template.loader import render_to_string
from .styles import AVAILABLE_STYLES_DICT
from .models import WalletLayout
from .views import get_layout_variables
logger = logging.getLogger("pretix.plugins.wallet")
class WalletSettingsHolder(BaseTicketOutput):
identifier = "wallet"
verbose_name = _("Wallet Output")
is_meta = True
is_enabled = False
preview_allowed = (
False # TODO: implement own preview view or hide button for meta-outputs
)
def settings_content_render(self, request) -> str:
return render_to_string(
"pretixplugins/wallet/settings_content.html", {"request": request}
)
class WalletOutput(BaseTicketOutput):
settings_form_fields = []
def __init__(self, event: Event):
super().__init__(event)
self.settings = SettingsSandbox(
"ticketoutput", WalletSettingsHolder.identifier, event
)
class GoogleWalletTicketOutput(WalletOutput):
identifier = "wallet_google"
verbose_name = _("Google")
download_button_text = "Add to Google Wallet"
class AppleWalletTicketOutput(WalletOutput):
identifier = "wallet_apple"
verbose_name = _("Apple")
download_button_text = "Add to Apple Wallet"
def generate(self, op):
order = op.order
event = order.event
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
# layout = self.override_layout_signal.send_chained(
# order.event, 'layout', orderposition=op, layout=self.layout_map.get(
# (op.item_id, self.override_channel or order.sales_channel.identifier),
# self.layout_map.get(
# (op.item_id, 'web'),
# self.default_layout
# )
# )
# )
layout = WalletLayout.objects.get(pk=1)
ticket = str(op.item.name)
if op.variation:
ticket += " - " + str(op.variation)
serialNumber = "%s-%s-%s-%d" % (
order.event.organizer.slug,
order.event.slug,
order.code,
op.pk,
)
context = {
"placeholders": get_layout_variables(op.order.event),
"evaluation_context": [op, order, order.event],
"ca_certificate": open(
"/Users/engelhardt/code/tmp/wallet/apple/ca_cert.pem", "rb"
).read(),
"certificate": open(
"/Users/engelhardt/code/tmp/wallet/apple/cert.pem", "rb"
).read(),
"key": open(
"/Users/engelhardt/code/tmp/wallet/apple/secret_key.pem", "rb"
).read(),
"password": None,
"description": _("Ticket for {event} ({product})").format( # TODO: i18n
event=event.name, product=ticket
),
"organizationName": event.organizer.name,
"passTypeIdentifier": "pass.test.test",
"teamIdentifier": "TEST123456",
"serialNumber": serialNumber,
"locales": event.settings.locales
}
assert layout.platform == "apple"
data = AVAILABLE_STYLES_DICT[layout.platform][layout.style].generate(
layout.layout, context
)
return filename, "application/vnd.apple.pkpass", data
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]

View File

@@ -0,0 +1,45 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.urls import re_path
from pretix.api.urls import event_router
from .views import (
LayoutEditorView,
LayoutCreateView,
LayoutListView
)
from .api import WalletLayoutViewSet
urlpatterns = [
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$',
LayoutListView.as_view(), name='index'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/(?P<platform>[^/]+)/$',
LayoutCreateView.as_view(), name='add'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
LayoutEditorView.as_view(), name='edit'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$', # TODO
LayoutEditorView.as_view(), name='default'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO
LayoutEditorView.as_view(), name='delete'),
]
event_router.register('walletlayouts', WalletLayoutViewSet)

View File

@@ -0,0 +1,119 @@
import json
from typing import Any
from django import forms
from django.http import Http404
from django.urls import reverse
from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, ListView
from pretix.base.pdf import get_images, get_variables
from pretix.control.permissions import EventPermissionRequiredMixin
from django.conf import settings
from .models import WalletLayout
from .styles import AVAILABLE_STYLES, AVAILABLE_PLATFORMS
def get_layout_variables(event):
return {
"text": get_variables(event),
"image": get_images(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):
model = WalletLayout
permission = "can_change_event_settings"
template_name = "pretixplugins/wallet/layout_list.html"
context_object_name = "layouts"
def get_queryset(self):
return self.request.event.wallet_layouts
def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
ctx = super().get_context_data(**kwargs)
ctx["platforms"] = AVAILABLE_PLATFORMS
return ctx
class LayoutEditorView(DetailView):
template_name = "pretixplugins/wallet/edit.html"
model = WalletLayout
permission = "event.settings.general:write"
pk_url_kwarg = "layout"
def get_platform_styles(self):
if self.object.platform not in AVAILABLE_STYLES:
raise Http404(
_("Unknown platform '{platform}'").format(platform=self.object.platform)
)
return AVAILABLE_STYLES[self.object.platform]
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["styles"] = {
style.identifier: style.asdict() for style in self.get_platform_styles()
}
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
class WalletLayoutCreateForm(forms.ModelForm):
class Meta:
model = WalletLayout
fields = ("name",)
def __init__(self, *args, platform, event, **kwargs):
super().__init__(*args, **kwargs)
self.platform = platform
self.event = event
def save(self, *args, **kwargs) -> Any:
self.instance.platform = self.platform
self.instance.event = self.event
return super().save(*args, **kwargs)
class LayoutCreateView(CreateView):
template_name = "pretixplugins/wallet/create.html"
form_class = WalletLayoutCreateForm
permission = "event.settings.general:write"
@property
def platform(self):
platform = self.kwargs["platform"]
if platform not in AVAILABLE_PLATFORMS:
raise Http404(_("Unknown platform '{platform}'").format(platform=platform))
return platform
def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs["platform"] = self.platform
kwargs["event"] = self.request.event
return kwargs
def get_success_url(self) -> str:
return reverse(
"plugins:wallet:edit",
kwargs={
"organizer": self.request.event.organizer.slug,
"event": self.request.event.slug,
"layout": self.object.pk,
},
)

View File

@@ -0,0 +1,186 @@
from pretix.plugins.wallet.styles.apple import SignedZipFile, StringResource, AppleWalletEventTicket
from django.utils.translation import gettext as _
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
import jsonschema
@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";'
def test_generate_pass_json():
context = {
"placeholders": {
"text": {"test_placeholder": {"evaluate": lambda: "test placeholder"}}
},
"description": "Ticket for Test",
"organizationName": "TestOrg",
"serialNumber": "1",
"passTypeIdentifier": "pass.test.test",
"teamIdentifier": "ABCDEF123456"
}
layout = {"fieldgroups": {"primary": {"entries": [{"type": "placeholder", "label": "test", "content": "test_placeholder"}, {"type": "text", "label": {"de":"test-de", "en": "test-en"}, "content": "test content"}]}}}
style = AppleWalletEventTicket()
schema = style.layout_schema(context)
jsonschema.validate(schema, layout)
result = style.generate_pass_json(layout, context)
required_fields = ["description", "formatVersion", "organizationName", "passTypeIdentifier", "serialNumber", "teamIdentifier"]
for field in required_fields:
assert field in result
assert result['formatVersion'] == 1
breakpoint()

View File

@@ -0,0 +1,336 @@
from pretix.plugins.wallet.styles.base import (
PassStyle,
PredefinedFieldGroup,
WalletPlatform,
PlaceholderFieldGroup,
FieldContentType,
PassLayout,
FieldGroupType,
FieldEntryType,
)
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 isinstance(group, PredefinedFieldGroup):
output += "PREDEFINED\n"
elif isinstance(group, PlaceholderFieldGroup):
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"
else:
raise ValueError("Unknown field group")
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"
)