WalletLayout now contain layouts for all platforms

This commit is contained in:
Kara Engelhardt
2026-05-19 18:04:20 +02:00
parent f6f4c1c56c
commit 97167f75c9
12 changed files with 259 additions and 185 deletions

View File

@@ -1,35 +1,21 @@
from rest_framework import viewsets
from django.db import transaction
from .styles import PassLayout, AVAILABLE_STYLES_DICT
from .models import WalletLayout
from .styles import PassLayout, AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS
from .models import WalletLayout, WalletPlatformLayout
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
from rest_framework import serializers
class WalletLayoutSerializer(I18nAwareModelSerializer):
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
platform = serializers.ChoiceField(choices=[p.identifier for p in AVAILABLE_PLATFORMS])
style = serializers.CharField(allow_null=True, required=False)
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
model = WalletPlatformLayout
fields = ("platform", "style", "layout")
def validate_layout(self, value):
if not isinstance(value, dict):
@@ -37,11 +23,10 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
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 = data.get('platform')
style = data.get('style')
layout = data.get('layout')
if platform and style and layout:
platform_styles = AVAILABLE_STYLES_DICT[platform]
if data["style"] not in platform_styles:
@@ -54,20 +39,38 @@ class WalletLayoutSerializer(I18nAwareModelSerializer):
return data
class WalletLayoutSerializer(I18nAwareModelSerializer):
platform_layouts = WalletPlatformLayoutSerializer(many=True)
class Meta:
model = WalletLayout
fields = ("id", "name", "platform_layouts")
read_only_fields = ("id",)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
def save(self, *args, **kwargs):
super().save(*args, **kwargs, event=self.context["event"])
def update(self, instance, validated_data):
platform_layouts = validated_data.pop('platform_layouts')
for layout in platform_layouts:
if layout['style']:
instance.platform_layouts.update_or_create(platform=layout['platform'], defaults=layout)
instance.platform_layouts.exclude(platform__in={layout['platform'] for layout in platform_layouts if layout['style'] is not None}).delete()
return super().update(instance, validated_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

View File

@@ -1,8 +1,8 @@
# Generated by Django 4.2.28 on 2026-03-17 16:29
# Generated by Django 5.2.13 on 2026-05-19 15:39
from django.db import migrations, models
import django.db.models.deletion
import pretix.base.models.base
from django.db import migrations, models
class Migration(migrations.Migration):
@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
initial = True
dependencies = [
("pretixbase", "0297_outgoingmail"),
("pretixbase", "0300_alter_customer_locale_alter_user_locale"),
]
operations = [
@@ -24,9 +24,6 @@ class Migration(migrations.Migration):
),
),
("name", models.CharField(max_length=190)),
("platform", models.CharField(max_length=10)),
("style", models.CharField(max_length=255)),
("layout", models.TextField()),
(
"event",
models.ForeignKey(
@@ -37,7 +34,64 @@ class Migration(migrations.Migration):
),
],
options={
"ordering": ("name",),
"abstract": False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name="WalletLayoutItem",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
(
"item",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="walletlayout_assignments",
to="pretixbase.item",
),
),
(
"layout",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="item_assignments",
to="wallet.walletlayout",
),
),
],
options={
"unique_together": {("item", "layout")},
},
),
migrations.CreateModel(
name="WalletPlatformLayout",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("platform", models.CharField(max_length=10)),
("style", models.CharField(max_length=255)),
("layout", models.JSONField(default=dict)),
(
"parent",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="platform_layouts",
to="wallet.walletlayout",
),
),
],
options={
"unique_together": {("parent", "platform")},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),

View File

@@ -24,6 +24,7 @@ from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel
from django_scopes import ScopedManager
from django.core.exceptions import ValidationError
class WalletLayout(LoggedModel):
@@ -36,28 +37,31 @@ class WalletLayout(LoggedModel):
max_length=190,
verbose_name=_('Name')
)
objects = ScopedManager(organizer='event__organizer')
class WalletPlatformLayout(LoggedModel):
parent = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts")
platform = models.CharField(max_length=10)
style = models.CharField(max_length=255)
layout = models.JSONField(default=dict)
objects = ScopedManager(organizer='event__organizer')
objects = ScopedManager(organizer='parent__event__organizer')
class Meta:
ordering = ("name",)
def __str__(self):
return self.name
unique_together = (('parent', 'platform'),)
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",)
unique_together = (('item', 'layout'),)
def clean(self):
if self.item.event != self.layout.event:
raise ValidationError("cannot bind layout to item of different event")

View File

@@ -9,8 +9,8 @@ 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 PLATFORMS: Platforms = JSON.parse(
document.querySelector("#platforms")?.textContent ?? "{}",
);
const VARIABLES: VariableConfig = JSON.parse(
document.querySelector("#variables")?.textContent ?? "{}",
@@ -55,11 +55,36 @@ function saveLayout(e: SubmitEvent) {
},
)
.then((x) => x.json())
.catch((x) => alert(x))
.then((x) => {
wallet_layout.value = x;
isLoading.value = false;
});
}
const currentPlatform = ref(PLATFORMS[0].identifier);
const currentLayout = computed(() => ({}));
const platformStyles = computed(() => {
for (const platform of PLATFORMS) {
if (platform.identifier === currentPlatform.value) {
return platform.styles
}
}
});
const platformLayout = computed(() => {
for (const layout of wallet_layout.value.platform_layouts) {
if (layout.platform === currentPlatform.value) {
return layout
}
}
const newLayout = {platform: currentPlatform, style: null, layout: {}};
wallet_layout.value.platform_layouts.push(newLayout);
return newLayout
});
const platformChoices = computed(() => {
return [[null, "Do not generate pass"], ...Object.values(platformStyles.value).map(x => [x.identifier, x.name])]
});
</script>
<template lang="pug">
@@ -68,24 +93,29 @@ function saveLayout(e: SubmitEvent) {
// 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
Input(label="Name" v-model="wallet_layout.name")
nav
ul.nav.nav-tabs
li(v-for="platform in PLATFORMS" :class="{'active': currentPlatform === platform.identifier}")
a(role="tab" @click="currentPlatform = platform.identifier") {{ platform.name }}
.tabbed-form.tab-content
.tab-pane.active.row
.col-md-8
Select.form-group(label="Style" v-model="platformLayout.style" :choices="platformChoices")
.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] }}
StyleSettings(v-if="platformLayout.style" v-model="platformLayout.layout" :style="platformStyles[platformLayout.style]" :variables="VARIABLES" :locales="LOCALES")
.col-md-4
.panel.panel-default
.panel-heading Preview
.panel-body
// TODO: Preview
pre
code {{ platformLayout }}
pre(v-if="wallet_layout.style")
code {{ platformStyles[wallet_layout.style] }}
pre
code {{ wallet_layout }}
.form-group.submit-group
button.btn.btn-primary.btn-save(type="submit") Submit
</template>

View File

@@ -8,7 +8,8 @@ defineOptions({
const props = defineProps<{
label?: string
choices: Array<[string, string]>
errors?: string[]
errors?: string[],
class: string
}>()
const modelValue = defineModel<string|null>();
const id = useId()
@@ -23,7 +24,7 @@ watchEffect(() => {
</script>
<template lang="pug">
template(v-if="choices.length >= 1")
template(v-if="choices.length >= 1" :class="props.class")
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] }}

View File

@@ -48,10 +48,16 @@ type Variable = {
label: string
};
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Styles = Record<string, Style>;
type Variables = Record<string, Variable>;
type VariableConfig = Record<string, Variables>;
type Platforms = Record<string, Platform>;
type PlaceholderFieldGroupConfig = {

View File

@@ -2,7 +2,8 @@ from .apple import ApplePlatform, AppleWalletEventTicket
from .google import GooglePlatform, GoogleWalletEventTicket
from .base import PassLayout
AVAILABLE_PLATFORMS = {"apple": ApplePlatform, "google": GooglePlatform}
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
AVAILABLE_STYLES = {
"apple": [AppleWalletEventTicket()],
"google": [
@@ -14,4 +15,4 @@ AVAILABLE_STYLES_DICT = {
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
}
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"]
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"]

View File

@@ -19,7 +19,6 @@ import json
from django.contrib.staticfiles import finders
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
@@ -236,13 +235,13 @@ class AppleWalletEventTicket(AppleWalletStyle):
raise ValueError("Unknown field group")
return fields
def convert_fields(self, strings, fields):
def convert_fields(self, strings, fields, prefix):
converted = []
for i,f in enumerate(fields):
converted_field = {**f, "key": f"primary-{i}"}
converted_field = {**f, "key": f"{prefix}-{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"
strings.add_entry(f"{prefix}-{i}-label", converted_field['label'])
converted_field['label'] = f"{prefix}-{i}-label"
converted.append(converted_field)
return converted
@@ -251,6 +250,6 @@ class AppleWalletEventTicket(AppleWalletStyle):
fields = self.get_pass_fields(layout, context)
return {
"eventTicket": {
"primaryFields": self.convert_fields(strings, fields['primary'])
"primaryFields": self.convert_fields(strings, fields['primary'], 'primary')
}
}

View File

@@ -11,7 +11,7 @@
{% block content %}
<h1>{% trans "Edit layout" %} {{ object.name }} </h1>
{{ styles|json_script:"styles" }}
{{ platforms|json_script:"platforms" }}
{{ variables|json_script:"variables" }}
{{ locales|json_script:"locales" }}
<div id="editor" data-layout-id="{{ object.pk }}"></div>

View File

@@ -5,79 +5,70 @@
{% 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 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>
{% if "event.settings.general:write" in request.eventpermset %}
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}"
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 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 %}?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 %}
{% endblock %}

View File

@@ -32,7 +32,7 @@ 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>[^/]+)/$',
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/$',
LayoutCreateView.as_view(), name='add'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
LayoutEditorView.as_view(), name='edit'),

View File

@@ -31,7 +31,6 @@ def get_editor_variables(event):
}
# TODO: should this even be a list view?
class LayoutListView(EventPermissionRequiredMixin, ListView):
model = WalletLayout
permission = "can_change_event_settings"
@@ -39,12 +38,7 @@ class LayoutListView(EventPermissionRequiredMixin, ListView):
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
return self.request.event.wallet_layouts.all()
class LayoutEditorView(DetailView):
@@ -53,18 +47,19 @@ class LayoutEditorView(DetailView):
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['platforms'] = [{
"identifier": platform.identifier,
"name": platform.name,
"styles": {
style.identifier: style.asdict() for style in AVAILABLE_STYLES.get(platform.identifier)
}
} for platform in AVAILABLE_PLATFORMS
]
# 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)
@@ -79,13 +74,11 @@ class WalletLayoutCreateForm(forms.ModelForm):
model = WalletLayout
fields = ("name",)
def __init__(self, *args, platform, event, **kwargs):
def __init__(self, *args, 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)
@@ -95,16 +88,8 @@ class LayoutCreateView(CreateView):
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