This commit is contained in:
Kara Engelhardt
2026-03-18 10:25:34 +01:00
parent affb32c513
commit cfcd0f4206
13 changed files with 536 additions and 40 deletions

View File

@@ -193,13 +193,15 @@ class BaseTicketOutput:
pass pass
@property @property
def show_settings(self) -> bool: def is_meta(self) -> bool:
""" """
Returns whether or not this output should be shown in the ticket settings. 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:: If you set this to false, you need to have some other mechanism to decide whether this output is enabled .. note:: You should set is_enabled to False for meta outputs.
""" """
return True return False
@property @property
def download_button_text(self) -> str: def download_button_text(self) -> str:

View File

@@ -965,7 +965,7 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
responses = register_ticket_outputs.send(self.request.event) responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses: for receiver, response in responses:
provider = response(self.request.event) 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 return provider
def get(self, request, *args, **kwargs): def get(self, request, *args, **kwargs):
@@ -1068,7 +1068,9 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
responses = register_ticket_outputs.send(self.request.event) responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses: for receiver, response in responses:
provider = response(self.request.event) provider = response(self.request.event)
if not provider.show_settings: 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 continue
provider.form = ProviderForm( provider.form = ProviderForm(
@@ -1080,17 +1082,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
provider.form.fields = OrderedDict( provider.form.fields = OrderedDict(
[ [
('ticketoutput_%s_%s' % (provider.identifier, k), v) ('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.form.prepare_fields()
provider.evaluated_preview_allowed = True provider.evaluated_preview_allowed = True
if not provider.preview_allowed: if not provider.preview_allowed:
provider.evaluated_preview_allowed = False provider.evaluated_preview_allowed = False
else: 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)): if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
provider.evaluated_preview_allowed = False provider.evaluated_preview_allowed = False
break break

View File

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

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,47 @@
#
# 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
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.TextField()
class Meta:
ordering = ("name",)
def __str__(self):
return self.name
# TODO:ScopedManager

View File

@@ -20,22 +20,16 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django.dispatch import receiver
from pretix.base.signals import register_ticket_outputs 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}")
@receiver(register_ticket_outputs, dispatch_uid="output_wallet") connect_signals()
def register_ticket_wallet(sender, **kwargs):
from .ticketoutput import WalletTicketOutput
return WalletTicketOutput
@receiver(register_ticket_outputs, dispatch_uid="output_wallet_apple")
def register_ticket_wallet_apple(sender, **kwargs):
from .ticketoutput import AppleWalletTicketOutput
return AppleWalletTicketOutput
@receiver(register_ticket_outputs, dispatch_uid="output_wallet_google")
def register_ticket_wallet_google(sender, **kwargs):
from .ticketoutput import GoogleWalletTicketOutput
return GoogleWalletTicketOutput

View File

@@ -0,0 +1,156 @@
from dataclasses import dataclass, field, asdict
from typing import Literal
import enum
from django.utils.translation import gettext_lazy as _
from django.core.exceptions import ValidationError
from i18nfield.strings import LazyI18nString
class PlaceholderFieldType(enum.Enum):
TEXT = "text"
CODE = "qr"
IMAGE = "image"
PREDEFINED = "predefined"
# TODO: POWERED_BY ?
@dataclass
class PlaceholderField:
type: PlaceholderFieldType
label: LazyI18nString
value: LazyI18nString
def asdict(self):
return {'type': self.type.value, 'label': self.label, 'value': self.value}
@dataclass
class FieldGroupDefinition:
name: str
identifier: str
entry_type: PlaceholderFieldType
min_entries: int | None = None
max_entries: int | None = None
def asdict(self):
return {"identifier": self.identifier, "name": self.name, "min_entries": self.min_entries, "max_entries": self.max_entries}
@dataclass
class PlaceholderFieldGroup(FieldGroupDefinition):
entry_type: PlaceholderFieldType = PlaceholderFieldType.TEXT
default_entries: list[PlaceholderField] = field(default_factory=list)
def asdict(self):
asdict = super().asdict()
asdict['default_entries'] = [x.asdict() for x in self.default_entries]
return asdict
@dataclass
class PredefinedFieldGroup(FieldGroupDefinition):
entry_type: PlaceholderFieldType = PlaceholderFieldType.PREDEFINED
min_entries = 0
max_entries = 1
class PassStyle:
identifier: str # unique within platform
name: str
platform: Literal["apple"] | Literal["google"]
fields: list[FieldGroupDefinition]
# preview_image: str # TODO: preview
def asdict(self):
return {
"identifier": self.identifier,
"name": self.name,
"platform": self.platform,
"fields": [x.asdict() for x in self.fields],
}
class AppleWalletEventTicket(PassStyle):
identifier = "event_1"
name = "Event Ticket Layout 1"
platform = "apple"
# 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)
fields = [
PlaceholderFieldGroup(
identifier="logo",
name=_("Logo"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderField(PlaceholderFieldType.IMAGE, "logo", "event:image")
],
entry_type=PlaceholderFieldType.IMAGE,
),
PlaceholderFieldGroup(identifier="primary", name=_("Primary"), min_entries=1, max_entries=1),
PlaceholderFieldGroup(
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."
PlaceholderFieldGroup(
identifier="headers", name=_("Header"), max_entries=3
), # TODO: header image
PlaceholderFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
PlaceholderFieldGroup(identifier="back", name=_("Back")),
]
# preview_image = "apple/event_ticket.svg"
class GoogleWalletEventTicket(PassStyle):
identifier = "event"
name = "Event Ticket"
platform = "google"
fields = [
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
PlaceholderFieldGroup(identifier="qrcode", name=_("QR-Code"), entry_type=PlaceholderFieldType.CODE),
]
AVAILABLE_STYLES = [AppleWalletEventTicket(), GoogleWalletEventTicket()]
def get_platforms_with_styles():
platforms_with_styles = {}
for style in AVAILABLE_STYLES:
platform = style.platform
if platform not in platforms_with_styles:
platforms_with_styles[platform] = {}
platforms_with_styles[platform][style.identifier] = style
return platforms_with_styles
def get_platform_styles(platform):
platform_styles = {}
for style in AVAILABLE_STYLES:
if style.platform == platform:
platform_styles[style.identifier] = style
return platform_styles
def get_platforms():
return sorted(set(style.platform for style in AVAILABLE_STYLES))
class PassLayout:
style: PassStyle
layout: dict
def __init__(self, style, layout):
self.style = style
self.layout = layout
def validate(self):
self.validate_fields()
def validate_fields(self):
style_fields = self.style.fields
if 'fields' not in self.layout:
raise ValidationError(_("Layout did not contain any fields"))
layout_fields = self.layout['fields']
if not isinstance(layout_fields, dict):
raise ValidationError(_("'fields' must be dict"))
for fieldgroup in style_fields:
layout_field_data = layout_fields.get(fieldgroup.identifier, [])
if fieldgroup.min_entries and fieldgroup.min_entries < len(layout_field_data):
raise ValidationError(_("At least {min_entries} must be specified for {name}").format(min_entries=fieldgroup.min_entries, name=fieldgroup.name))

View File

@@ -0,0 +1,17 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<pre><code>{{ styles }}</code></pre>
<pre><code>{{ variables }}</code></pre>
{{ styles|json_script:"styles" }}
{{ variables|json_script:"variables" }}
<form method="post">
{% csrf_token %}
{{form}}
<button type="submit" class="btn btn-default">
{% trans "Submit " %}
</button>
</form>
{% endblock %}

View File

@@ -0,0 +1,66 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load money %}
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %}
<h1>{% trans "Wallet layouts" %}</h1>
<div class="tabbed-form">
{% for platform in platforms %}
<fieldset>
<legend>{{platform}}</legend>
</fieldset>
{% endfor %}
</div>
{% comment %} <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:ticketoutputpdf: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:ticketoutputpdf: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:ticketoutputpdf: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:ticketoutputpdf: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:ticketoutputpdf: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>
{% endcomment %}
{% include "pretixcontrol/pagination.html" %}
{% endblock %}

View File

@@ -19,27 +19,39 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
from collections import OrderedDict
import logging import logging
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.ticketoutput import BaseTicketOutput from pretix.base.ticketoutput import BaseTicketOutput
from django import forms from pretix.base.models import Event
from pretix.base.settings import SettingsSandbox
logger = logging.getLogger('pretix.plugins.wallet') logger = logging.getLogger('pretix.plugins.wallet')
class WalletTicketOutput(BaseTicketOutput): class WalletSettingsHolder(BaseTicketOutput):
identifier = 'wallet' identifier = 'wallet'
verbose_name = _('Wallet output') verbose_name = _('Wallet Output')
is_enabeld = False is_meta = True
preview_allowed = False is_enabled = False
preview_allowed = False # TODO: implement own preview view or hide button for meta-outputs
class GoogleWalletTicketOutput(BaseTicketOutput): class WalletOutput(BaseTicketOutput):
identifier = 'google_wallet' settings_form_fields = []
verbose_name = _('google')
show_settings = False def __init__(self, event: Event):
super().__init__(event)
class AppleWalletTicketOutput(BaseTicketOutput): self.settings = SettingsSandbox('ticketoutput', WalletSettingsHolder.identifier, event)
identifier = 'apple_wallet'
verbose_name = _('apple') class GoogleWalletTicketOutput(WalletOutput):
show_settings = False 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"
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]

View File

@@ -0,0 +1,36 @@
#
# 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 .views import (
EditorView,
LayoutListView
)
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/edit/(?P<platform>[^/]+)/$',
EditorView.as_view(), name='edit'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<platform>[^/]+)/(?P<layout>[^/]+)/$',
EditorView.as_view(), name='edit'),
]

View File

@@ -0,0 +1,118 @@
from typing import Any
from django.http import Http404
from django.shortcuts import redirect
from django.urls import reverse
from django.views.generic import FormView, ListView, TemplateView
from pretix.base.pdf import get_variables
from pretix.control.permissions import EventPermissionRequiredMixin
from .styles import PassLayout, get_platform_styles, get_platforms
from .models import WalletLayout
import json
from django.utils.translation import gettext_lazy as _
from django import forms
from django.core.exceptions import ValidationError
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"] = get_platforms()
return ctx
class EditorForm(forms.Form):
name = forms.CharField()
style = forms.TypedChoiceField()
layout = forms.JSONField(initial={})
def __init__(self, platform, **kwargs):
super().__init__(**kwargs)
self.platform = platform
self.platform_styles = get_platform_styles(platform)
self.fields["style"].choices = [
(id, style.name) for id, style in self.platform_styles.items()
]
self.fields["style"].coerce = self.coerce_style
def coerce_style(self, value):
return self.platform_styles[value]
def clean_layout(self):
layout = self.cleaned_data["layout"]
if not isinstance(layout, dict):
raise ValidationError(_("Layout must be a dict"))
return layout
def clean(self):
if "style" in self.cleaned_data and "layout" in self.cleaned_data:
layout = PassLayout(
style=self.cleaned_data["style"], layout=self.cleaned_data["layout"]
)
layout.validate()
return self.cleaned_data
class EditorView(EventPermissionRequiredMixin, FormView):
template_name = "pretixplugins/wallet/edit.html"
form_class = EditorForm
success_url = ""
permission = "can_change_event_settings"
@property
def platform(self):
return self.kwargs["platform"]
def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs()
kwargs["platform"] = self.platform
return kwargs
def get_platform_styles(self):
if self.platform not in get_platforms():
raise Http404(
_("Unknown platform '{platform}'").format(platform=self.platform)
)
return get_platform_styles(self.platform)
def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs)
context["styles"] = {
id: style.asdict() for id, style in self.get_platform_styles().items()
}
context["variables"] = {
"text": {
varname: {"label": var["label"], "editor_sample": var["editor_sample"]}
for varname, var in get_variables(self.request.event).items()
}
}
return context
def form_valid(self, form):
self.object = WalletLayout.objects.create(
event=self.request.event,
name=form.cleaned_data["name"],
platform=self.platform,
style=form.cleaned_data["style"],
layout=form.cleaned_data["layout"],
)
return redirect(
reverse(
"plugins:wallet:edit",
kwargs={
"organizer": self.request.event.organizer.slug,
"event": self.request.event.slug,
"platform": self.platform,
"layout": self.object.pk,
},
)
)