From c8e65e5db05e702dd4f7efc9c67b045dce048a79 Mon Sep 17 00:00:00 2001 From: Kara Engelhardt Date: Fri, 9 Oct 2026 16:50:51 +0200 Subject: [PATCH] localization; bugfixes; google event ticket --- src/pretix/base/services/tickets.py | 40 +++-- src/pretix/plugins/wallet/i18n.py | 65 +++++++ ...tlayoutitem_layout_googlewalletinstance.py | 52 ++++++ src/pretix/plugins/wallet/placeholders.py | 162 +++++++++--------- .../components/preview/pass-preview.vue | 4 +- src/pretix/plugins/wallet/styles/apple.py | 104 +++++------ src/pretix/plugins/wallet/styles/base.py | 57 +++--- src/pretix/plugins/wallet/styles/google.py | 116 +++++++++---- src/pretix/plugins/wallet/views.py | 17 +- 9 files changed, 389 insertions(+), 228 deletions(-) create mode 100644 src/pretix/plugins/wallet/i18n.py create mode 100644 src/pretix/plugins/wallet/migrations/0004_alter_walletlayoutitem_layout_googlewalletinstance.py diff --git a/src/pretix/base/services/tickets.py b/src/pretix/base/services/tickets.py index 8bc6bd0c16..0ff59dc9d3 100644 --- a/src/pretix/base/services/tickets.py +++ b/src/pretix/base/services/tickets.py @@ -21,24 +21,27 @@ # import logging import os +import uuid +from datetime import datetime, timezone from decimal import Decimal -from datetime import datetime, timedelta, timezone + from django.core.files.base import ContentFile +from django.db import transaction from django.utils.timezone import now from django.utils.translation import gettext as _ from django_scopes import scopes_disabled +from i18nfield.strings import LazyI18nString from pretix.base.i18n import language from pretix.base.models import ( - CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order, - OrderPosition, CachedFile + CachedCombinedTicket, CachedFile, CachedTicket, Event, InvoiceAddress, + Order, OrderPosition, ) from pretix.base.services.tasks import EventTask, ProfiledTask from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.signals import register_ticket_outputs from pretix.celery_app import app from pretix.helpers.database import rolledback_transaction -from django.db import transaction logger = logging.getLogger(__name__) @@ -64,10 +67,11 @@ def generate_orderposition(order_position: int, provider: str, cf: CachedFile | for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider): ct.delete() ct = CachedTicket.objects.create(order_position=order_position, provider=provider, - extension=ext, type=ttype, file=None) + extension=ext, type=ttype, file=None) ct.file.save(filename, ContentFile(data)) return ct.pk + def generate_order(order: int, provider: str, cf: CachedFile | None = None): order = Order.objects.select_related('event').get(id=order) @@ -89,10 +93,11 @@ def generate_order(order: int, provider: str, cf: CachedFile | None = None): for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider): ct.delete() ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext, - type=ttype, file=None) + type=ttype, file=None) ct.file.save(filename, ContentFile(data)) return ct.pk + @app.task(base=ProfiledTask) def generate(model: str, pk: int, provider: str, cachedfile_pk: int | None = None): with scopes_disabled(): @@ -109,18 +114,19 @@ def generate(model: str, pk: int, provider: str, cachedfile_pk: int | None = Non class DummyRollbackException(Exception): pass + def get_preview_position(event): connection = transaction.get_connection() if not connection.in_atomic_block: raise RuntimeError("get_preview_position needs to be called in a rolledback_transaction") - item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'), - description=_("Sample product description")) - item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40')) + item = event.items.create(name=LazyI18nString.from_gettext("Sample product"), default_price=Decimal('42.23'), + description=_("Sample product description")) + item2 = event.items.create(name=LazyI18nString.from_gettext("Sample workshop"), default_price=Decimal('23.40')) item.program_times.create(item=item, start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc), - end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc)) + end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc)) - from pretix.base.models import Order + from pretix.base.models import Order, SeatingPlan order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(), email='sample@pretix.eu', locale=event.settings.locale, @@ -129,7 +135,16 @@ def get_preview_position(event): scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme] sample = {k: str(v) for k, v in scheme['sample'].items()} - position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price) + + SeatingPlan.objects.create( + name="Plan", organizer=event.organizer, layout="{}" + ) + event.seat_category_mappings.create( + layout_category='Stalls', product=item + ) + seat = event.seats.create(seat_number="1", row_label="C", product=item, seat_guid=uuid.uuid4()) + + position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price, seat=seat) s = event.subevents.first() order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s) order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=position, subevent=s) @@ -137,6 +152,7 @@ def get_preview_position(event): InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company")) return position + def preview(event: int, provider: str, provider_arguments: dict = {}): event = Event.objects.get(id=event) diff --git a/src/pretix/plugins/wallet/i18n.py b/src/pretix/plugins/wallet/i18n.py new file mode 100644 index 0000000000..416f97a8dc --- /dev/null +++ b/src/pretix/plugins/wallet/i18n.py @@ -0,0 +1,65 @@ +from typing import Protocol, runtime_checkable + +from django.utils import translation +from django.utils.formats import date_format + +type MaybeTranslatedString = str | Localizable + + +@runtime_checkable +class Localizable(Protocol): + def localize(self, lng: str) -> str: + ... + + +class LocalizableBase: + def localize(self, lng): + with translation.override(lng): + return str(self) + + +class FormattingLocalizableString(LocalizableBase): + def __init__( + self, + base_str: MaybeTranslatedString, + *format_args: MaybeTranslatedString, + **format_kwargs: MaybeTranslatedString, + ): + self.base_str = base_str + self.format_args = format_args + self.format_kwargs = format_kwargs + + def __str__(self): + return str(self.base_str).format(*self.format_args, **self.format_kwargs) + + +class GettextLocalizableString(LocalizableBase): + def __init__(self, base): + self.base = base + + @classmethod + def gettext(cls, base): + """Fake function so makemessages picks up these strings.""" + return cls(base) + + def __str__(self): + return translation.gettext(self.base) + + +class LocalizableDatetime(LocalizableBase): + def __init__(self, datetime, format): + self.datetime = datetime + self.format = format + + def __str__(self): + return date_format(self.datetime, self.format) + + +def localizable_to_dict(string: MaybeTranslatedString, locales: list[str]): + translated = {} + + for locale in locales: + with translation.override(locale): + translated[locale] = str(string) + + return translated diff --git a/src/pretix/plugins/wallet/migrations/0004_alter_walletlayoutitem_layout_googlewalletinstance.py b/src/pretix/plugins/wallet/migrations/0004_alter_walletlayoutitem_layout_googlewalletinstance.py new file mode 100644 index 0000000000..7164027e1a --- /dev/null +++ b/src/pretix/plugins/wallet/migrations/0004_alter_walletlayoutitem_layout_googlewalletinstance.py @@ -0,0 +1,52 @@ +# Generated by Django 5.2.17 on 2026-10-06 14:29 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ( + "pretixbase", + "0312_alter_customer_locale_alter_devicelastseen_device_and_more", + ), + ("wallet", "0003_walletlayoutfilesetting"), + ] + + operations = [ + migrations.AlterField( + model_name="walletlayoutitem", + name="layout", + field=models.ForeignKey( + null=True, + on_delete=django.db.models.deletion.CASCADE, + related_name="item_assignments", + to="wallet.walletlayout", + ), + ), + migrations.CreateModel( + name="GoogleWalletInstance", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False + ), + ), + ("type", models.CharField()), + ("identifier", models.CharField()), + ("data", models.JSONField()), + ( + "event", + models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + to="pretixbase.event", + ), + ), + ], + options={ + "unique_together": {("type", "identifier")}, + }, + ), + ] diff --git a/src/pretix/plugins/wallet/placeholders.py b/src/pretix/plugins/wallet/placeholders.py index ad5b300863..4a13c734c2 100644 --- a/src/pretix/plugins/wallet/placeholders.py +++ b/src/pretix/plugins/wallet/placeholders.py @@ -6,11 +6,14 @@ from django.contrib.staticfiles import finders from django.core.files import File from django.dispatch import receiver from django.templatetags.static import static -from django.utils.formats import date_format -from i18nfield.strings import LazyI18nString +from pretix.base.models.event import Event from pretix.base.templatetags.money import money_filter from pretix.multidomain.urlreverse import eventreverse_absolute +from pretix.plugins.wallet.i18n import ( + FormattingLocalizableString, GettextLocalizableString, LocalizableDatetime, + MaybeTranslatedString, +) from .signals import register_wallet_placeholders @@ -38,12 +41,12 @@ class BaseWalletPlaceholder: raise NotImplementedError() @property - def label(self) -> LazyI18nString: + def label(self) -> MaybeTranslatedString: """The human readable name of this placeholder""" raise NotImplementedError() @property - def control_label(self) -> LazyI18nString: + def control_label(self) -> MaybeTranslatedString: """ The human readable name of this placeholder shown in the backend. @@ -63,7 +66,7 @@ class BaseWalletTextPlaceholder(BaseWalletPlaceholder): def content_type(self) -> str: return "text" - def render(self, **context) -> LazyI18nString | str | None: + def render(self, **context) -> MaybeTranslatedString | None: """ This method is called to generate the text that is being shown on the pass. You will be passed the keyword arguments specified in ``required_context``. @@ -71,7 +74,7 @@ class BaseWalletTextPlaceholder(BaseWalletPlaceholder): """ raise NotImplementedError() - def render_sample(self, **context) -> LazyI18nString | str: + def render_sample(self, **context) -> MaybeTranslatedString: """ This method is called to generate a text to be used in previews. @@ -79,8 +82,8 @@ class BaseWalletTextPlaceholder(BaseWalletPlaceholder): If those instances contain all data needed, you do not need to implement this. """ sample = self.render(**context) - if sample is None: - raise RuntimeError("`render` returned None when rendering a sample") + if not sample: + raise RuntimeError("`render` returned falsy value when rendering a sample") return sample @@ -111,11 +114,11 @@ class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder): def __init__( self, identifier: str, - label: LazyI18nString, + label: MaybeTranslatedString, args: set[str], - func: Callable[..., LazyI18nString | str | None], + func: Callable[..., MaybeTranslatedString | None], sample: ( - None | LazyI18nString | str | Callable[..., str | LazyI18nString] + None | MaybeTranslatedString | Callable[..., MaybeTranslatedString] ) = None, ): self._identifier = identifier @@ -124,6 +127,9 @@ class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder): self.render = func self._sample = sample + def __repr__(self): + return f"FunctionalWalletTextPlaceholder(identifier={self.identifier!r}, label={self.label!r})" + @property def identifier(self): return self._identifier @@ -136,7 +142,7 @@ class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder): def required_context(self) -> set[str]: return self._args - def render_sample(self, **context) -> LazyI18nString | str: + def render_sample(self, **context) -> MaybeTranslatedString: if isinstance(self._sample, Callable): return self._sample(**context) elif self._sample: @@ -149,7 +155,7 @@ class FunctionalWalletImagePlaceholder(BaseWalletImagePlaceholder): def __init__( self, identifier: str, - label: LazyI18nString, + label: MaybeTranslatedString, args: set[str], func: Callable[..., File | None], sample: None | str | Callable[..., str] = None, @@ -334,13 +340,7 @@ def get_wallet_placeholder_renderer(**kwargs): "seat", {"order_position"}, lambda order_position: ( - order_position.seat - if order_position.seat_id - else ( - order_position.addon_to.seat - if order_position.addon_to_id - else None - ) + order_position.seat if order_position.seat_id else None ), ), ], @@ -364,15 +364,19 @@ def get_static_file(name) -> File | None: def admission(subevent_or_event): return ( - subevent_or_event.date_admission.astimezone(subevent_or_event.timezone) + LocalizableDatetime( + subevent_or_event.date_admission.astimezone(subevent_or_event.timezone), + "SHORT_DATETIME_FORMAT", + ) if subevent_or_event.date_admission else None ) def date_from(subevent_or_event): - return subevent_or_event.get_date_from_display( - subevent_or_event.timezone, short=True + return LocalizableDatetime( + subevent_or_event.date_from.astimezone(subevent_or_event.timezone), + "SHORT_DATETIME_FORMAT", ) @@ -395,62 +399,60 @@ def website(order_position, order): register_wallet_placeholders, dispatch_uid="plugin_wallet_register_wallet_placeholders", ) -def base_text_placeholders(sender, **kwargs): +def base_text_placeholders(sender: Event, **kwargs): return [ FunctionalWalletTextPlaceholder( "event", - LazyI18nString.from_gettext("Event"), + GettextLocalizableString.gettext("Event"), {"event"}, lambda event: event.name, ), FunctionalWalletTextPlaceholder( "event_slug", - LazyI18nString.from_gettext("Event Slug"), + GettextLocalizableString.gettext("Event Slug"), {"event"}, lambda event: event.slug, ), FunctionalWalletTextPlaceholder( "admission", - LazyI18nString.from_gettext("Admission"), + GettextLocalizableString.gettext("Admission"), {"subevent_or_event"}, admission, - lambda subevent_or_event: subevent_or_event.get_date_from_display( - subevent_or_event.timezone, short=True - ), + date_from, ), FunctionalWalletTextPlaceholder( "date_from", - LazyI18nString.from_gettext("Begin"), + GettextLocalizableString.gettext("Begin"), {"subevent_or_event"}, date_from, ), FunctionalWalletTextPlaceholder( "admission_or_date_from", - LazyI18nString.from_gettext("Admission or Date From"), + GettextLocalizableString.gettext("Admission or Date From"), {"subevent_or_event"}, admission_or_date_from, ), FunctionalWalletTextPlaceholder( "order", - LazyI18nString.from_gettext("Order Code"), + GettextLocalizableString.gettext("Order Code"), {"order"}, lambda order: order.code, ), FunctionalWalletTextPlaceholder( "total", - LazyI18nString.from_gettext("Order Total"), + GettextLocalizableString.gettext("Order Total"), {"event", "order"}, lambda event, order: money_filter(order.total, event.currency), ), FunctionalWalletTextPlaceholder( "order_email", - LazyI18nString.from_gettext("Order Email"), + GettextLocalizableString.gettext("Order Email"), {"order"}, lambda order: order.email, ), FunctionalWalletTextPlaceholder( "price", - LazyI18nString.from_gettext("Item Price"), + GettextLocalizableString.gettext("Item Price"), {"event", "order_position"}, lambda event, order_position: money_filter( order_position.price, event.currency @@ -458,74 +460,78 @@ def base_text_placeholders(sender, **kwargs): ), FunctionalWalletTextPlaceholder( "secret", - LazyI18nString.from_gettext("Order Secret (QR-Code-Content)"), + GettextLocalizableString.gettext("Order Secret (QR-Code-Content)"), {"order_position"}, lambda order_position: order_position.secret, ), FunctionalWalletTextPlaceholder( "item", - LazyI18nString.from_gettext("Product"), + GettextLocalizableString.gettext("Product"), {"item"}, lambda item: item.name, ), FunctionalWalletTextPlaceholder( "item_description", - LazyI18nString.from_gettext("Product description"), + GettextLocalizableString.gettext("Product description"), {"item"}, lambda item: item.description, ), FunctionalWalletTextPlaceholder( "item_with_variation", - LazyI18nString.from_gettext("Product and variation"), + GettextLocalizableString.gettext("Product and variation"), {"order_position"}, lambda order_position: ( - f"{order_position.item.name} - {order_position.variation}" + FormattingLocalizableString( + "{name} - {variation}", + name=order_position.item.name, + variation=order_position.variation.name, + ) if order_position.variation else order_position.item.name ), ), FunctionalWalletTextPlaceholder( "seat", - LazyI18nString.from_gettext("Seat: Full name"), - {"event", "seat"}, - lambda event, seat: ( - seat - if seat - else ( - LazyI18nString.from_gettext("General admission") - if event.seating_plan_id is not None - else "" - ) + GettextLocalizableString.gettext("Seat: Full name"), + {"seat"}, + lambda seat: ( + FormattingLocalizableString("{seat}", seat=seat) if seat else None ), - LazyI18nString.from_gettext("Ground floor, Row 3, Seat 4"), + GettextLocalizableString.gettext("Ground floor, Row 3, Seat 4") ), FunctionalWalletTextPlaceholder( "seat_zone", - LazyI18nString.from_gettext("Seat: zone"), - {"event", "seat"}, - lambda event, seat: ( - seat.zone_name - if seat - else ( - LazyI18nString.from_gettext("General admission") - if event.seating_plan_id is not None - else "" - ) - ), - LazyI18nString.from_gettext("Ground floor"), + GettextLocalizableString.gettext("Seat: Zone"), + {"seat"}, + lambda seat: seat.zone_name, + GettextLocalizableString.gettext("Ground floor"), + ), + FunctionalWalletTextPlaceholder( + "seat_row", + GettextLocalizableString.gettext("Seat: Row"), + {"seat"}, + lambda seat: seat.row_label or seat.row_name, + "C", + ), + FunctionalWalletTextPlaceholder( + "seat_number", + GettextLocalizableString.gettext("Seat: Seat Number"), + {"seat"}, + lambda seat: seat.seat_label or seat.seat_number, + "5", ), FunctionalWalletTextPlaceholder( "attendee_name", - LazyI18nString.from_gettext("Attendee name"), + GettextLocalizableString.gettext("Attendee name"), {"order_position"}, lambda order_position: order_position.attendee_name, ), FunctionalWalletTextPlaceholder( "program_start", - LazyI18nString.from_gettext("Programm times: Start"), + GettextLocalizableString.gettext("Programm times: Start"), {"item", "subevent_or_event"}, lambda item, subevent_or_event: ( - date_format( + LocalizableDatetime( min(pt.start for pt in item.program_times.all()).astimezone( subevent_or_event.timezone ), @@ -537,10 +543,10 @@ def base_text_placeholders(sender, **kwargs): ), FunctionalWalletTextPlaceholder( "program_end", - LazyI18nString.from_gettext("Programm times: End"), + GettextLocalizableString.gettext("Programm times: End"), {"item", "subevent_or_event"}, lambda item, subevent_or_event: ( - date_format( + LocalizableDatetime( max(pt.end for pt in item.program_times.all()).astimezone( subevent_or_event.timezone ), @@ -552,56 +558,56 @@ def base_text_placeholders(sender, **kwargs): ), FunctionalWalletTextPlaceholder( "organizer", - LazyI18nString.from_gettext("Organizer"), + GettextLocalizableString.gettext("Organizer"), {"organizer"}, lambda organizer: organizer.name, ), FunctionalWalletTextPlaceholder( "organizer_contact", - LazyI18nString.from_gettext("Organizer Contact"), + GettextLocalizableString.gettext("Organizer Contact"), {"event"}, lambda event: event.settings.contact_mail, "contact@example.com", ), FunctionalWalletTextPlaceholder( "order_code", - LazyI18nString.from_gettext("Order Code"), + GettextLocalizableString.gettext("Order Code"), {"order"}, lambda order: order.code, ), FunctionalWalletTextPlaceholder( "purchase_date", - LazyI18nString.from_gettext("Purchase Date"), + GettextLocalizableString.gettext("Purchase Date"), {"order", "subevent_or_event"}, - lambda order, subevent_or_event: date_format( + lambda order, subevent_or_event: LocalizableDatetime( order.datetime.astimezone(subevent_or_event.timezone), "SHORT_DATETIME_FORMAT", ), ), FunctionalWalletTextPlaceholder( "website", - LazyI18nString.from_gettext("Website"), + GettextLocalizableString.gettext("Website"), {"order_position", "order"}, website, ), # IMAGES FunctionalWalletImagePlaceholder( "poweredby", - LazyI18nString.from_gettext("Logo"), + GettextLocalizableString.gettext("Logo"), set(), lambda: get_static_file("pretixplugins/wallet/logo.png"), static("pretixplugins/wallet/logo.png"), ), FunctionalWalletImagePlaceholder( "poweredby_icon", - LazyI18nString.from_gettext("Icon"), + GettextLocalizableString.gettext("Icon"), set(), lambda: get_static_file("pretixplugins/wallet/icon.png"), static("pretixplugins/wallet/icon.png"), ), FunctionalWalletImagePlaceholder( "example_no_preview", - LazyI18nString.from_gettext("Image with no preview"), + GettextLocalizableString.gettext("Image with no preview"), set(), lambda: get_static_file("pretix_passbook/icon.png"), ), diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/preview/pass-preview.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/preview/pass-preview.vue index 196e0456df..89a14d7f0c 100644 --- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/preview/pass-preview.vue +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/preview/pass-preview.vue @@ -28,8 +28,8 @@ const layoutStyle = computed(() => { v = `url(${URL.createObjectURL(val.file)})`; // TODO: encode } else if (settingDef.type == "image" && val && val.url) { v = `url(${val.url})`; - } else if (settingDef.type == "image" && settingDef.attrs.default) { - v = `url(${settingDef.attrs.default})`; + } else if (settingDef.type == "image" && settingDef.default) { + v = `url(${settingDef.default})`; } else { v = val; } diff --git a/src/pretix/plugins/wallet/styles/apple.py b/src/pretix/plugins/wallet/styles/apple.py index c1d8123a38..7dfccc975c 100644 --- a/src/pretix/plugins/wallet/styles/apple.py +++ b/src/pretix/plugins/wallet/styles/apple.py @@ -14,21 +14,17 @@ from django.conf import settings from django.contrib.staticfiles import finders from django.core.files.uploadedfile import SimpleUploadedFile from django.forms import ValidationError -from django.templatetags.static import static from django.utils.encoding import force_bytes -from django.utils.translation import gettext as _, override -from i18nfield.strings import LazyI18nString +from django.utils.translation import gettext as _ from pretix.base.models import OrderPosition +from pretix.plugins.wallet.i18n import ( + FormattingLocalizableString, GettextLocalizableString, Localizable, + MaybeTranslatedString, +) from pretix.plugins.wallet.styles.base import ( - ColorSettingsField, - FieldGroup, - FieldGroupDisplay, - FloatSettingsField, - ImageSettingsField, - PassStyle, - PlaceholderFieldEntry, - TextFieldGroup, + ColorSettingsField, FieldGroup, FieldGroupDisplay, FloatSettingsField, + ImageSettingsField, PassStyle, PlaceholderFieldEntry, TextFieldGroup, WalletPlatform, ) @@ -40,32 +36,15 @@ class ApplePlatform(WalletPlatform): name = _("Apple") -class FormattedLazyI18nString: - def __init__(self, base_str: LazyI18nString, **format_args: str): - self.base_str = base_str - self.format_args = format_args - - def localize(self, language): - return self.base_str.localize(language).format(**self.format_args) - - -def lazyi18nstring_from_gettext(text: str, locales: set[str]) -> LazyI18nString: - data = {} - for locale in locales: - with override(locale): - data[locale] = _(text) - return LazyI18nString(data) - - class StringResource: - entries: dict[str, LazyI18nString | FormattedLazyI18nString] + entries: dict[str, MaybeTranslatedString] locales: set[str] def __init__(self, locales): self.entries = {} self.locales = set(locales) - def add_entry(self, key: str, value: LazyI18nString | FormattedLazyI18nString): + def add_entry(self, key: str, value: MaybeTranslatedString): if key in self.entries: raise ValueError(f"{key} already exists in this StringResource") self.entries[key] = value @@ -78,9 +57,10 @@ class StringResource: def generate_resource(self, language): output = "" for key, entry in self.entries.items(): - output += ( - f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n' - ) + if isinstance(entry, str): + output += f'"{self.escape(key)}" = "{self.escape(entry)}";\n' + else: + output += f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n' return output.strip() def generate(self): @@ -208,12 +188,16 @@ class AppleWalletStyle(PassStyle): event = op.subevent or op.order.event tz = event.timezone - ticket = str(op.item.name) - if op.variation: - ticket += " - " + str(op.variation) + ticket = ( + FormattingLocalizableString( + "{name} - {variation}", name=op.item.name, variation=op.variation.name + ) + if op.variation + else op.item.name + ) - description = FormattedLazyI18nString( - LazyI18nString.from_gettext("Ticket for {event} ({product})"), + description = FormattingLocalizableString( + GettextLocalizableString.gettext("Ticket for {event} ({product})"), event=self.event.name, product=ticket, ) @@ -236,14 +220,13 @@ class AppleWalletStyle(PassStyle): **self.pass_content(fields, strings), } + # TODO: Apples specs says this should be "specified as a CSS-style RGB triple, such as rgb(100, 10, 110).", but hex codes work as well if bg_color := self.layout["settings"].get("bg_color"): - pass_json["backgroundColor"] = ( - bg_color # TODO: specified as a CSS-style RGB triple, such as rgb(100, 10, 110). - ) + pass_json["backgroundColor"] = bg_color if fg_color := self.layout["settings"].get("fg_color"): - pass_json["foregroundColor"] = fg_color # TODO: specified as a CSS-style RGB triple, such as rgb(100, 10, 110). + pass_json["foregroundColor"] = fg_color if label_color := self.layout["settings"].get("label_color"): - pass_json["labelColor"] = label_color # TODO: specified as a CSS-style RGB triple, such as rgb(100, 10, 110). + pass_json["labelColor"] = label_color if "expirationDate" not in pass_json: if op.valid_until: @@ -289,7 +272,6 @@ class AppleWalletStyle(PassStyle): "longitude": float(event.get_lon), } - print(pass_json) return pass_json def generate(self, op: OrderPosition): @@ -297,7 +279,6 @@ class AppleWalletStyle(PassStyle): filename = "{}-{}.pkpass".format(order.event.slug, order.code) fields = self.get_pass_fields(op) - pkpass = SignedZipFile( self.event.settings.wallet_apple_ca_certificate.read(), self.event.settings.wallet_apple_certificate.read(), @@ -332,6 +313,7 @@ class AppleWalletStyle(PassStyle): pkpass.add_file("background.png", background.read()) for lang, content in strings.generate().items(): + print(lang, content) pkpass.add_file(f"{lang}.lproj/pass.strings", content) pkpass.add_file("pass.json", json.dumps(pass_json)) result = pkpass.finish() @@ -360,8 +342,8 @@ class AppleWalletEventTicket(AppleWalletStyle): default_entries=[ PlaceholderFieldEntry( content="admission_or_date_from", - label=lazyi18nstring_from_gettext( - "Admission", self.event.settings.locales + label=GettextLocalizableString.gettext( + "Admission" ), ), ], @@ -388,8 +370,8 @@ class AppleWalletEventTicket(AppleWalletStyle): default_entries=[ PlaceholderFieldEntry( content="item_with_variation", - label=lazyi18nstring_from_gettext( - "Product", self.event.settings.locales + label=GettextLocalizableString.gettext( + "Product" ), ) ], @@ -404,8 +386,8 @@ class AppleWalletEventTicket(AppleWalletStyle): default_entries=[ PlaceholderFieldEntry( content="seat", - label=lazyi18nstring_from_gettext( - "Seat", locales=self.event.settings.locales + label=GettextLocalizableString.gettext( + "Seat" ), ), PlaceholderFieldEntry( @@ -413,14 +395,14 @@ class AppleWalletEventTicket(AppleWalletStyle): ), PlaceholderFieldEntry( content="program_start", - label=lazyi18nstring_from_gettext( - "From", locales=self.event.settings.locales + label=GettextLocalizableString.gettext( + "From" ), ), PlaceholderFieldEntry( content="program_end", - label=lazyi18nstring_from_gettext( - "To", locales=self.event.settings.locales + label=GettextLocalizableString.gettext( + "To" ), ), ], @@ -450,8 +432,8 @@ class AppleWalletEventTicket(AppleWalletStyle): ), PlaceholderFieldEntry( content="order_email", - label=lazyi18nstring_from_gettext( - "Ordered by", self.event.settings.locales + label=GettextLocalizableString.gettext( + "Ordered by" ), ), PlaceholderFieldEntry( @@ -511,16 +493,20 @@ class AppleWalletEventTicket(AppleWalletStyle): ] def convert_fields(self, strings, fields, prefix): + # TODO: detect if a string-value/string-label is the same as a generated field name + # eg if a placeholder returns "primary-0-label", this would not be shown to the user but instead replaces with the label converted = [] for i, f in enumerate(fields): converted_field = {**f, "key": f"{prefix}-{i}"} if "label" in converted_field and isinstance( - converted_field["label"], LazyI18nString + converted_field["label"], Localizable ): strings.add_entry(f"{prefix}-{i}-label", converted_field["label"]) converted_field["label"] = f"{prefix}-{i}-label" - if isinstance(converted_field["value"], LazyI18nString): + if isinstance( + converted_field["value"], Localizable + ): strings.add_entry(f"{prefix}-{i}-value", converted_field["value"]) converted_field["value"] = f"{prefix}-{i}-value" converted.append(converted_field) diff --git a/src/pretix/plugins/wallet/styles/base.py b/src/pretix/plugins/wallet/styles/base.py index fdd9422288..d292f01cb7 100644 --- a/src/pretix/plugins/wallet/styles/base.py +++ b/src/pretix/plugins/wallet/styles/base.py @@ -4,20 +4,17 @@ from typing import Any, Literal, TypedDict import jsonschema from django.core.exceptions import ValidationError from django.core.files import File +from django.templatetags.static import static from i18nfield.strings import LazyI18nString from pretix.api.helpers import handle_file_upload from pretix.base.models import OrderPosition +from ..i18n import localizable_to_dict from ..placeholders import ( - WalletPlaceholderRenderer, - get_available_context, - get_wallet_placeholder_renderer, - get_wallet_placeholders, + MaybeTranslatedString, WalletPlaceholderRenderer, get_available_context, + get_wallet_placeholder_renderer, get_wallet_placeholders, ) -from django.templatetags.static import static -from urllib.parse import urljoin -from pretix.multidomain.urlreverse import eventreverse_absolute class WalletPlatform: @@ -61,7 +58,7 @@ class FieldGroup: ) -> dict: raise NotImplementedError() - def asdict(self, context: LayoutContext): + def asdict(self, locales, context: LayoutContext): return { "type": self.type.value, "identifier": self.identifier, @@ -83,44 +80,42 @@ class FieldEntryType(enum.Enum): class FieldEntry[T]: type: FieldEntryType - label: LazyI18nString | None + label: MaybeTranslatedString | None content: T def __init__( - self, type: FieldEntryType, content: T, label: LazyI18nString | None = None + self, type: FieldEntryType, content: T, label: MaybeTranslatedString | None = None ): self.type = type self.label = label self.content = content - def asdict(self) -> dict: + def asdict(self, locales) -> dict: return { "type": self.type.value, "content": self.content, - "label": self.label.data if self.label is not None else None, + "label": localizable_to_dict(self.label, locales) if self.label is not None else None, } class PlaceholderFieldEntry(FieldEntry[str]): type = FieldEntryType.PLACEHOLDER - label: LazyI18nString | None content: str - def __init__(self, content: str, label: LazyI18nString | None = None): + def __init__(self, content: str, label: MaybeTranslatedString | None = None): self.label = label self.content = content -class CustomFieldEntry(FieldEntry[LazyI18nString]): +class CustomFieldEntry(FieldEntry[MaybeTranslatedString]): type: FieldEntryType - label: LazyI18nString | None - content: LazyI18nString + content: MaybeTranslatedString - def asdict(self) -> dict: + def asdict(self, locales) -> dict: return { "type": self.type.value, - "content": self.content.data, - "label": self.label.data if self.label else None, + "content": localizable_to_dict(self.content, locales), + "label": localizable_to_dict(self.label, locales) if self.label else None, } @@ -170,11 +165,11 @@ class PlaceholderFieldGroup(FieldGroup): if self.required and (self.min_entries is None or self.min_entries < 1): self.min_entries = 1 - def asdict(self, context: LayoutContext): + def asdict(self, locales, context: LayoutContext): return { - **super().asdict(context), + **super().asdict(locales, context), "content_type": self.content_type.value, - "default_entries": [x.asdict() for x in self.default_entries], + "default_entries": [x.asdict(locales) for x in self.default_entries], "display": self.display.value, "min_entries": self.min_entries, "max_entries": self.max_entries, @@ -283,7 +278,7 @@ class SettingsField: required: bool default: str | None - def asdict(self): + def asdict(self, locales): return { "identifier": self.identifier, "label": self.label, @@ -398,7 +393,7 @@ class FloatSettingsField(SettingsField): self.default = default def layout_schema(self): - schema = {"type": "number"} + schema: dict[str, Any] = {"type": "number"} if self.min is not None: schema["minimum"] = self.min if self.max is not None: @@ -407,8 +402,8 @@ class FloatSettingsField(SettingsField): schema = {"oneOf": [schema, {"type": "null"}]} return schema - def asdict(self): - return super().asdict() | {"min": self.min, "max": self.max} + def asdict(self, locales): + return super().asdict(locales) | {"min": self.min, "max": self.max} class PassStyle: @@ -466,9 +461,9 @@ class PassStyle: return { "identifier": self.identifier, "name": self.name, - "fieldgroups": [x.asdict(context) for x in self.fieldgroups], + "fieldgroups": [x.asdict(self.event.settings.locales, context) for x in self.fieldgroups], "preview_layout": self.preview_layout, - "settings": [x.asdict() for x in self.settings], + "settings": [x.asdict(self.event.settings.locales) for x in self.settings], } def layout_schema(self): @@ -580,7 +575,7 @@ class PassStyle: for field in self.layout["fieldgroups"][group.identifier][ "entries" ]: - field_entry = {} + field_entry: dict[str, MaybeTranslatedString | None] = {} if group.display == FieldGroupDisplay.WITH_LABEL: field_entry["label"] = LazyI18nString(field["label"]) if field["type"] == FieldEntryType.PLACEHOLDER.value: @@ -592,7 +587,7 @@ class PassStyle: and not str(field_entry["label"]) and label ): - field_entry["label"] = LazyI18nString(label) + field_entry["label"] = label elif field["type"] == FieldEntryType.CUSTOM.value: field_entry["value"] = LazyI18nString(field["content"]) diff --git a/src/pretix/plugins/wallet/styles/google.py b/src/pretix/plugins/wallet/styles/google.py index 900720812b..3cb63a49f6 100644 --- a/src/pretix/plugins/wallet/styles/google.py +++ b/src/pretix/plugins/wallet/styles/google.py @@ -1,43 +1,29 @@ +import uuid from functools import cached_property from urllib.parse import urljoin -import uuid from django.conf import settings +from django.db import transaction from django.utils import translation +from django.utils.formats import date_format from django.utils.translation import gettext as _ from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject from walletobjects.comms import Comms from walletobjects.constants import ( - Barcode, - ClassType, - MultipleDevicesAndHoldersAllowedStatus, - ObjectState, - ObjectType, + AnimationType, Barcode, ClassType, ConfirmationCode, DoorsOpen, + MultipleDevicesAndHoldersAllowedStatus, ObjectState, ObjectType, ReviewStatus, - AnimationType, - DoorsOpen, - ConfirmationCode ) - from pretix.base.models import Event, OrderPosition from pretix.base.settings import GlobalSettingsObject from pretix.multidomain.urlreverse import eventreverse_absolute from pretix.plugins.wallet.models import GoogleWalletInstance, GoogleWalletType from pretix.plugins.wallet.styles.base import ( - ColorSettingsField, - FieldGroup, - FieldGroupDisplay, - FloatSettingsField, - ImageFieldGroup, - ImageSettingsField, - PassStyle, - PlaceholderFieldEntry, - PredefinedFieldGroup, - TextFieldGroup, - WalletPlatform, + ColorSettingsField, FieldGroupDisplay, FloatSettingsField, ImageFieldGroup, + ImageSettingsField, PassStyle, PlaceholderFieldEntry, PredefinedFieldGroup, + TextFieldGroup, WalletPlatform, ) -from django.db import transaction SHIMMER = False @@ -310,15 +296,31 @@ class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle): { "fieldgroup": "date", "sample": [ - {"content": "01/01/1970", "label": "Date"}, - {"content": "12:34", "label": "Time"}, + { + "content": date_format( + self.event.date_from.astimezone( + self.event.timezone + ), + "SHORT_DATE_FORMAT", + ), + "label": "Date", + }, + { + "content": date_format( + self.event.date_from.astimezone( + self.event.timezone + ), + "TIME_FORMAT", + ), + "label": "Time", + }, ], }, { "fieldgroup": "seating", "sample": [ - {"content": "5", "label": "Row"}, - {"content": "2", "label": "Seat"}, + {"content": _("Ground floor"), "label": _("Section")}, + {"content": "5 / 2", "label": _("Row") + " / " + _("Seat")}, ], }, {"fieldgroup": "code"}, @@ -339,6 +341,22 @@ class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle): {"content": self.venue()[1], "label": self.venue()[0]}, ], }, + { + "fieldgroup": "date", + "sample": [ + { + "content": date_format( + self.event.date_from.astimezone( + self.event.timezone + ), + "DATETIME_FORMAT", + ), + "label": _("Event Start Time"), + }, + ], + }, + {"value": "ABCDE-1", "label": _("Ticket Number")}, + {"value": "ABCDE", "label": _("Order Number")}, ] }, ] @@ -364,8 +382,22 @@ class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle): output_class = super()._generate_class() if self.group_is_active("venue") and all(self.venue()): output_class.venue(*self.venue()) + + if self.group_is_active("date"): + if self.event.date_from: + output_class.start(self.event.date_from.isoformat()) + + if self.event.settings.show_date_to and self.event.date_to: + output_class.end(self.event.date_to.isoformat()) + + if self.event.date_admission: + output_class.doors_open( + DoorsOpen.doorsOpen, self.event.date_admission.isoformat() + ) + if SHIMMER: output_class.securityAnimation(AnimationType.FOIL_SHIMMER) + print(output_class) return output_class def ticket_holder_name(self, op): @@ -382,14 +414,31 @@ class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle): output_object.reservation_info(op.order.code) output_object.ticket_number(op.code) - if self.group_is_active("venue") and self.ticket_holder_name(op): + if self.group_is_active("ticket_holder") and self.ticket_holder_name(op): output_object.ticket_holder_name(self.ticket_holder_name(op)) - # output_object.ticket_type( - # get_translated_dict( - # str(op.item) + (" – " + str(op.variation.value) if op.variation else ""), - # op.order.event.settings.get('locales') - # ) - # ) + + if self.group_is_active("seating") and (seat := self.op.seat): + if seat.zone_name: + output_object.section(seat.zone_name) + + if seat.row_label: + output_object.row(seat.row_label) + elif seat.row_name: + output_object.row(seat.row_name) + + if seat.seat_label: + output_object.seat(seat.seat_label) + elif seat.seat_number: + output_object.seat(seat.seat_number) + + output_object.valid_time_interval(op.valid_from, op.valid_until) + output_object.ticket_type( + get_translated_dict( + str(op.item) + + (" – " + str(op.variation.value) if op.variation else ""), + op.order.event.settings.get("locales"), + ) + ) # places = django_settings.CURRENCY_PLACES.get(op.order.event.currency, 2) # output_object.face_value(int(op.price * 1000 ** places), op.order.event.currency) @@ -411,4 +460,5 @@ class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle): # ) # return self._comms().put_item(ObjectType.eventTicketObject, object_name, output_object) + print(output_object) return output_object diff --git a/src/pretix/plugins/wallet/views.py b/src/pretix/plugins/wallet/views.py index 9280545825..2130996dc9 100644 --- a/src/pretix/plugins/wallet/views.py +++ b/src/pretix/plugins/wallet/views.py @@ -14,7 +14,6 @@ from django.utils.translation import gettext_lazy as _ from django.views.generic import ( CreateView, DeleteView, DetailView, ListView, View, ) -from i18nfield.strings import LazyI18nString from pretix_vrpayment_wero.payment import HttpRequest from pretix.base.i18n import language @@ -23,6 +22,7 @@ from pretix.base.services.tickets import get_preview_position from pretix.control.permissions import EventPermissionRequiredMixin from pretix.helpers.database import rolledback_transaction from pretix.helpers.models import modelclone +from pretix.plugins.wallet.i18n import localizable_to_dict from .models import WalletLayout from .placeholders import ( @@ -33,13 +33,6 @@ from .styles import ( ) -def localize_lazyi18n_string(string: LazyI18nString, locales): - data = {} - for locale in locales: - data[locale] = string.localize(locale) - return data - - def get_editor_placeholders(event): with ( rolledback_transaction(), @@ -50,11 +43,7 @@ def get_editor_placeholders(event): placeholders = { t: { pid: { - "label": ( - localize_lazyi18n_string(p.label, event.settings.locales) - if isinstance(p.label, LazyI18nString) - else str(p.label) - ), + "label": (localizable_to_dict(p.label, event.settings.locales)), "sample": str(context.render_sample(p)), "required_context": list(sorted(p.required_context)), } @@ -220,6 +209,8 @@ class LayoutPreviewView(EventPermissionRequiredMixin, View): layout.validate() fname, mimet, data = layout.generate(p) + if mimet == "text/uri-list": + return redirect(data) resp = HttpResponse(data, content_type=mimet) ftype = fname.split(".")[-1] if not mimet.startswith("text/"):