From cfcd0f4206dbeba6ff42d5483282610bcf150d9a Mon Sep 17 00:00:00 2001 From: Kara Engelhardt Date: Wed, 18 Mar 2026 10:25:34 +0100 Subject: [PATCH] WIP --- src/pretix/base/ticketoutput.py | 10 +- src/pretix/control/views/event.py | 12 +- src/pretix/control/views/orders.py | 2 + .../plugins/wallet/migrations/0001_initial.py | 44 +++++ .../plugins/wallet/migrations/__init__.py | 0 src/pretix/plugins/wallet/models.py | 47 ++++++ src/pretix/plugins/wallet/signals.py | 26 ++- src/pretix/plugins/wallet/styles.py | 156 ++++++++++++++++++ .../templates/pretixplugins/wallet/edit.html | 17 ++ .../pretixplugins/wallet/layout_list.html | 66 ++++++++ src/pretix/plugins/wallet/ticketoutput.py | 42 +++-- src/pretix/plugins/wallet/urls.py | 36 ++++ src/pretix/plugins/wallet/views.py | 118 +++++++++++++ 13 files changed, 536 insertions(+), 40 deletions(-) create mode 100644 src/pretix/plugins/wallet/migrations/0001_initial.py create mode 100644 src/pretix/plugins/wallet/migrations/__init__.py create mode 100644 src/pretix/plugins/wallet/models.py create mode 100644 src/pretix/plugins/wallet/styles.py create mode 100644 src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html create mode 100644 src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html create mode 100644 src/pretix/plugins/wallet/urls.py create mode 100644 src/pretix/plugins/wallet/views.py diff --git a/src/pretix/base/ticketoutput.py b/src/pretix/base/ticketoutput.py index 6f6047ee8..8219cce11 100644 --- a/src/pretix/base/ticketoutput.py +++ b/src/pretix/base/ticketoutput.py @@ -193,13 +193,15 @@ class BaseTicketOutput: pass @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 def download_button_text(self) -> str: diff --git a/src/pretix/control/views/event.py b/src/pretix/control/views/event.py index a070c5363..e7db7c43d 100644 --- a/src/pretix/control/views/event.py +++ b/src/pretix/control/views/event.py @@ -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,7 +1068,9 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV responses = register_ticket_outputs.send(self.request.event) for receiver, response in responses: 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 provider.form = ProviderForm( @@ -1080,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 diff --git a/src/pretix/control/views/orders.py b/src/pretix/control/views/orders.py index 57155ad89..59ff20e0d 100644 --- a/src/pretix/control/views/orders.py +++ b/src/pretix/control/views/orders.py @@ -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', diff --git a/src/pretix/plugins/wallet/migrations/0001_initial.py b/src/pretix/plugins/wallet/migrations/0001_initial.py new file mode 100644 index 000000000..184f8d018 --- /dev/null +++ b/src/pretix/plugins/wallet/migrations/0001_initial.py @@ -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), + ), + ] diff --git a/src/pretix/plugins/wallet/migrations/__init__.py b/src/pretix/plugins/wallet/migrations/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/src/pretix/plugins/wallet/models.py b/src/pretix/plugins/wallet/models.py new file mode 100644 index 000000000..b19efaaf2 --- /dev/null +++ b/src/pretix/plugins/wallet/models.py @@ -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 . +# +# 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 +# . +# +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 diff --git a/src/pretix/plugins/wallet/signals.py b/src/pretix/plugins/wallet/signals.py index 40a6e5c6d..f67f2a613 100644 --- a/src/pretix/plugins/wallet/signals.py +++ b/src/pretix/plugins/wallet/signals.py @@ -20,22 +20,16 @@ # . # -from django.dispatch import receiver 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") -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 +connect_signals() diff --git a/src/pretix/plugins/wallet/styles.py b/src/pretix/plugins/wallet/styles.py new file mode 100644 index 000000000..e2cc6b233 --- /dev/null +++ b/src/pretix/plugins/wallet/styles.py @@ -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)) + + \ No newline at end of file diff --git a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html new file mode 100644 index 000000000..955ce9cc1 --- /dev/null +++ b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/edit.html @@ -0,0 +1,17 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load money %} +{% block title %}{% trans "Wallet layouts" %}{% endblock %} +{% block content %} +
{{ styles }}
+
{{ variables }}
+ {{ styles|json_script:"styles" }} + {{ variables|json_script:"variables" }} +
+ {% csrf_token %} + {{form}} + +
+{% endblock %} diff --git a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html new file mode 100644 index 000000000..e29cce4e4 --- /dev/null +++ b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html @@ -0,0 +1,66 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load money %} +{% block title %}{% trans "Wallet layouts" %}{% endblock %} +{% block content %} +

{% trans "Wallet layouts" %}

+
+ {% for platform in platforms %} +
+ {{platform}} +
+ {% endfor %} +
+ {% comment %}
+ + + + + + + + + + {% for l in layouts %} + + + + + + {% endfor %} + +
{% trans "Name" %}{% trans "Default" %}
+ {% if "can_change_event_settings" in request.eventpermset %} + + {{ l.name }} + + {% else %} + {{ l.name }} + {% endif %} + + {% if l.default %} + + + {% trans "Default" %} + + {% elif "can_change_event_settings" in request.eventpermset %} +
+ {% csrf_token %} + +
+ {% endif %} +
+ {% if "can_change_event_settings" in request.eventpermset %} + + + + {% endif %} +
+
+ {% endcomment %} + {% include "pretixcontrol/pagination.html" %} +{% endblock %} diff --git a/src/pretix/plugins/wallet/ticketoutput.py b/src/pretix/plugins/wallet/ticketoutput.py index 84cb029fe..f76a764c5 100644 --- a/src/pretix/plugins/wallet/ticketoutput.py +++ b/src/pretix/plugins/wallet/ticketoutput.py @@ -19,27 +19,39 @@ # You should have received a copy of the GNU Affero General Public License along with this program. If not, see # . # -from collections import OrderedDict import logging from django.utils.translation import gettext_lazy as _ 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') -class WalletTicketOutput(BaseTicketOutput): +class WalletSettingsHolder(BaseTicketOutput): identifier = 'wallet' - verbose_name = _('Wallet output') + verbose_name = _('Wallet Output') - is_enabeld = False - preview_allowed = False + is_meta = True + is_enabled = False + preview_allowed = False # TODO: implement own preview view or hide button for meta-outputs -class GoogleWalletTicketOutput(BaseTicketOutput): - identifier = 'google_wallet' - verbose_name = _('google') - show_settings = False - -class AppleWalletTicketOutput(BaseTicketOutput): - identifier = 'apple_wallet' - verbose_name = _('apple') - show_settings = False \ No newline at end of file +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" + +OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput] diff --git a/src/pretix/plugins/wallet/urls.py b/src/pretix/plugins/wallet/urls.py new file mode 100644 index 000000000..5e11ecde2 --- /dev/null +++ b/src/pretix/plugins/wallet/urls.py @@ -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 . +# +# 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 +# . +# +from django.urls import re_path + +from .views import ( + EditorView, + LayoutListView +) + +urlpatterns = [ + re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/wallet/$', + LayoutListView.as_view(), name='index'), + re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/wallet/edit/(?P[^/]+)/$', + EditorView.as_view(), name='edit'), + re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/wallet/edit/(?P[^/]+)/(?P[^/]+)/$', + EditorView.as_view(), name='edit'), +] diff --git a/src/pretix/plugins/wallet/views.py b/src/pretix/plugins/wallet/views.py new file mode 100644 index 000000000..02e6d8637 --- /dev/null +++ b/src/pretix/plugins/wallet/views.py @@ -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, + }, + ) + )