diff --git a/src/pretix/plugins/wallet/api.py b/src/pretix/plugins/wallet/api.py index 73ee642d84..6bd9681dc1 100644 --- a/src/pretix/plugins/wallet/api.py +++ b/src/pretix/plugins/wallet/api.py @@ -1,6 +1,6 @@ from rest_framework import viewsets from django.db import transaction -from .styles import PassLayout, AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS +from .styles import AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS from .models import WalletLayout, WalletPlatformLayout from pretix.api.serializers.i18n import I18nAwareModelSerializer from django.core.exceptions import ValidationError @@ -33,9 +33,8 @@ class WalletPlatformLayoutSerializer(I18nAwareModelSerializer): raise ValidationError(_("Invalid style")) style = platform_styles[data["style"]] - layout = PassLayout(style=style, layout=data["layout"]) - context = {"placeholders": get_editor_placeholders(self.context['event'])} - layout.validate(context=context) + style = style(event=self.context['event'], layout=data["layout"]) + style.validate() return data diff --git a/src/pretix/plugins/wallet/models.py b/src/pretix/plugins/wallet/models.py index a125f6a518..d6c118026c 100644 --- a/src/pretix/plugins/wallet/models.py +++ b/src/pretix/plugins/wallet/models.py @@ -23,12 +23,12 @@ from django.db import models from django.db.models import constraints, Q from django.utils.translation import gettext_lazy as _ -from pretix.base.models import LoggedModel +from pretix.base.models import LoggedModel, OrderPosition from django_scopes import ScopedManager from django.core.exceptions import ValidationError from pretix.plugins.wallet.styles import get_style -from pretix.plugins.wallet.styles.base import PassLayout +from pretix.plugins.wallet.styles.base import PassStyle class WalletLayout(LoggedModel): @@ -69,7 +69,10 @@ class WalletPlatformLayout(LoggedModel): @property def pass_layout(self): style = get_style(self.platform, self.style) - return PassLayout(style=style, layout=self.layout) + if style: + return style(event=self.parent.event, layout=self.layout) + else: + raise RuntimeError(f"Style {self.platform}.{self.style} not found") class WalletLayoutItem(models.Model): item = models.OneToOneField('pretixbase.Item', null=True, blank=True, related_name='walletlayout', @@ -79,3 +82,10 @@ class WalletLayoutItem(models.Model): def clean(self): if self.item.event != self.layout.event: raise ValidationError("cannot bind layout to item of different event") + +# smth like this for apple, lets see what the best architecture for google will be +# class AppleWalletPass(models.Model): +# platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT) +# order_position = models.ForeignKey(OrderPosition, on_delete=models.PROTECT) +# content = models.BinaryField() +# updated_at = models.DateTimeField(null=True, auto_now=True) \ No newline at end of file diff --git a/src/pretix/plugins/wallet/styles/__init__.py b/src/pretix/plugins/wallet/styles/__init__.py index b8afe87c61..429486b998 100644 --- a/src/pretix/plugins/wallet/styles/__init__.py +++ b/src/pretix/plugins/wallet/styles/__init__.py @@ -1,22 +1,21 @@ from .apple import ApplePlatform, AppleWalletEventTicket from .google import GooglePlatform, GoogleWalletEventTicket -from .base import PassLayout +from .base import PassStyle AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform] -AVAILABLE_STYLES = { - "apple": [AppleWalletEventTicket()], - "google": [ - GoogleWalletEventTicket() - ], +AVAILABLE_STYLES: dict[str, list[type[PassStyle]]] = { + "apple": [AppleWalletEventTicket], + "google": [GoogleWalletEventTicket], } AVAILABLE_STYLES_DICT = { plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items() } -def get_style(platform: str, identifier: str): + +def get_style(platform: str, identifier: str) -> type[PassStyle] | None: return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier) -__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"] +__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassStyle"] diff --git a/src/pretix/plugins/wallet/styles/apple.py b/src/pretix/plugins/wallet/styles/apple.py index fc2cd21e69..a96faec3d9 100644 --- a/src/pretix/plugins/wallet/styles/apple.py +++ b/src/pretix/plugins/wallet/styles/apple.py @@ -10,7 +10,6 @@ from .base import ( WalletPlatform, PassStyle, PlaceholderFieldEntry, - PassLayout, ) from django.utils.translation import gettext as _ from i18nfield.strings import LazyI18nString @@ -30,42 +29,6 @@ class ApplePlatform(WalletPlatform): identifier = "apple" name = _("Apple") - @classmethod - def generate(cls, layout: PassLayout, op: OrderPosition): - context = cls.get_context(op) - - order = op.order - event = order.event - filename = "{}-{}.pkpass".format(order.event.slug, order.code) - - ticket = str(op.item.name) - if op.variation: - ticket += " - " + str(op.variation) - - serialNumber = "%s-%s-%s-%d" % ( - order.event.organizer.slug, - order.event.slug, - order.code, - op.pk, - ) - - context.update({ - "ca_certificate": order.event.settings.wallet_apple_ca_certificate.read(), - "certificate": order.event.settings.wallet_apple_certificate.read(), - "key": order.event.settings.wallet_apple_key.read(), - "password": order.event.settings.wallet_apple_key_password, - "description": _("Ticket for {event} ({product})").format( # TODO: i18n - event=event.name, product=ticket - ), - "organizationName": event.organizer.name, - "passTypeIdentifier": order.event.settings.wallet_apple_pass_type_id, - "teamIdentifier": order.event.settings.wallet_apple_team_id, - "serialNumber": serialNumber, - }) - - data = layout.generate(context) - return filename, "application/vnd.apple.pkpass", data - class StringResource: # mapping string in default event locale -> LazyI18nString @@ -181,10 +144,39 @@ class AppleWalletStyle(PassStyle): } return pass_json - def generate(self, layout, context): - for key in ["ca_certificate", "certificate", "key", "password", "locales"]: - if key not in context: - raise ValueError(f"{key} missing from context") + def generate(self, op: OrderPosition): + context = self.get_context(op) + + order = op.order + event = order.event + filename = "{}-{}.pkpass".format(order.event.slug, order.code) + + ticket = str(op.item.name) + if op.variation: + ticket += " - " + str(op.variation) + + serialNumber = "%s-%s-%s-%d" % ( + order.event.organizer.slug, + order.event.slug, + order.code, + op.pk, + ) + + context.update({ + "ca_certificate": order.event.settings.wallet_apple_ca_certificate.read(), + "certificate": order.event.settings.wallet_apple_certificate.read(), + "key": order.event.settings.wallet_apple_key.read(), + "password": order.event.settings.wallet_apple_key_password, + "description": _("Ticket for {event} ({product})").format( # TODO: i18n + event=event.name, product=ticket + ), + "organizationName": event.organizer.name, + "passTypeIdentifier": order.event.settings.wallet_apple_pass_type_id, + "teamIdentifier": order.event.settings.wallet_apple_team_id, + "serialNumber": serialNumber, + }) + + fields = self.get_pass_fields(layout, context) @@ -214,7 +206,9 @@ class AppleWalletStyle(PassStyle): for lang, content in strings.generate().items(): pkpass.add_file(f"{lang}.lproj/pass.strings", content) pkpass.add_file("pass.json", json.dumps(pass_json)) - return pkpass.finish() + result = pkpass.finish() + return filename, "application/vnd.apple.pkpass", result + class AppleWalletEventTicket(AppleWalletStyle): diff --git a/src/pretix/plugins/wallet/styles/base.py b/src/pretix/plugins/wallet/styles/base.py index 98f4bed4fb..82752f96c1 100644 --- a/src/pretix/plugins/wallet/styles/base.py +++ b/src/pretix/plugins/wallet/styles/base.py @@ -9,23 +9,6 @@ class WalletPlatform: identifier: str name: str - @classmethod - def generate(cls, layout: "PassLayout", op: OrderPosition) -> Any: # TODO: Typing - pass - - @classmethod - def get_context(cls, op): - from ..placeholders import get_wallet_placeholders, WalletPlaceholderContext - - order = op.order - event = order.event - - return { - "placeholders": get_wallet_placeholders(event), - "placeholder_context": WalletPlaceholderContext(event=event, order=order, order_position=op), - "locale": event.settings.locale, # TODO: should probably be order locale - "locales": event.settings.locales, - } class FieldGroupType(enum.Enum): PLACEHOLDER = "placeholder" @@ -253,19 +236,21 @@ class PassStyle: fieldgroups: list[FieldGroup] preview_layout: list | None - def asdict(self): + @classmethod + def asdict(cls): return { - "identifier": self.identifier, - "name": self.name, - "fieldgroups": [x.asdict() for x in self.fieldgroups], - "preview_layout": self.preview_layout + "identifier": cls.identifier, + "name": cls.name, + "fieldgroups": [x.asdict() for x in cls.fieldgroups], + "preview_layout": cls.preview_layout } - def layout_schema(self, context): + @classmethod + def layout_schema(cls, context): schema = { "$schema": "https://json-schema.org/draft/2020-12/schema", # TODO: $id - "title": self.name, + "title": cls.name, "type": "object", "properties": { "fieldgroups": { @@ -273,12 +258,12 @@ class PassStyle: "type": "object", "properties": { group.identifier: group.layout_schema( - context=context, remaining_fields=self.fieldgroups[i:] + context=context, remaining_fields=cls.fieldgroups[i:] ) - for (i, group) in enumerate(self.fieldgroups) + for (i, group) in enumerate(cls.fieldgroups) }, "required": [ - group.identifier for group in self.fieldgroups if group.required + group.identifier for group in cls.fieldgroups if group.required ], } }, @@ -291,17 +276,15 @@ class PassStyle: } }, } - if any(group.required for group in self.fieldgroups): + if any(group.required for group in cls.fieldgroups): schema["required"] = ["fieldgroups"] return schema - def generate(self, layout, context): - raise NotImplementedError() - - def render_placeholder(self, context, content_type, content): + @classmethod + def render_placeholder(cls, context, content_type, content): placeholder = ( - context.get("placeholders") + context.get("placeholders", {}) .get(content_type, {}) .get(content) ) @@ -312,15 +295,31 @@ class PassStyle: return None, None - def get_pass_fields(self, layout, context): + + def __init__(self, event, layout): + self.event = event + self.layout = layout + + def get_layout_context(self): + return {"placeholders": {}} + + def validate(self): + schema = self.layout_schema(self.get_layout_context()) + try: + jsonschema.validate(self.layout, schema) + except jsonschema.ValidationError as e: + raise ValidationError("Invalid layout: {}".format(str(e))) + + def get_pass_fields(self, context): fields = {} for group in self.fieldgroups: if isinstance(group, PredefinedFieldGroup): pass + elif isinstance(group, PlaceholderFieldGroup): group_fields = fields.get(group.identifier, []) - if group.identifier in layout["fieldgroups"]: - for field in layout["fieldgroups"][group.identifier]["entries"]: + if group.identifier in self.layout["fieldgroups"]: + for field in self.layout["fieldgroups"][group.identifier]["entries"]: field_entry = {} if group.display == FieldGroupDisplay.WITH_LABEL: field_entry["label"] = LazyI18nString(field["label"]) @@ -338,30 +337,13 @@ class PassStyle: f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided" ) fields[group.identifier] = group_fields[: group.max_entries] - if (overflow_group := layout["fieldgroups"][group.identifier]['overflow']): + if (overflow_group := self.layout["fieldgroups"][group.identifier]['overflow']): fields.setdefault(overflow_group, []) fields[overflow_group] += group_fields[group.max_entries:] + else: raise ValueError("Unknown field group") return fields - -class PassLayout: - style: PassStyle - layout: dict - - def __init__(self, style, layout): - self.style = style - self.layout = layout - - def validate(self, context): - schema = self.style.layout_schema(context) - try: - jsonschema.validate(self.layout, schema) - except jsonschema.ValidationError as e: - raise ValidationError("Invalid layout: {}".format(str(e))) - - def generate(self, context): - # TODO: how to handle nonexisting placeholders here? - self.validate(context) - return self.style.generate(self.layout, context) \ No newline at end of file + def generate(self, op: OrderPosition): + raise NotImplementedError() \ No newline at end of file diff --git a/src/pretix/plugins/wallet/styles/google.py b/src/pretix/plugins/wallet/styles/google.py index a4786b1cb0..2c9d2614cd 100644 --- a/src/pretix/plugins/wallet/styles/google.py +++ b/src/pretix/plugins/wallet/styles/google.py @@ -3,7 +3,6 @@ from pretix.base.models import Event, OrderPosition from .base import ( FieldGroupDisplay, ImageFieldGroup, - PassLayout, PassStyle, PlaceholderFieldEntry, PredefinedFieldGroup, @@ -90,52 +89,28 @@ class GooglePlatform(WalletPlatform): identifier = "google" name = _("Google") - @classmethod - def generate(cls, layout: PassLayout, op: OrderPosition): - context = cls.get_context(op) - - order = op.order - event = order.event - - context.update( - { - "credentials": event.settings.get("wallet_google_credentials").read(), - "issuerName": event.organizer.name, - "eventName": event.name, - # TODO: use other classId and objectId in preview mode - "classId": get_class_id(event), - "objectId": get_object_id(op), - "homepageUrl": eventreverse_absolute(event, "presale:event.index"), - # TODO: add webhook view & register in pass - # "webhookUrl": eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",) - } - ) - - data = layout.generate(context) - return "url", "text/plain", data - class GoogleWalletStyle(PassStyle): platform = GooglePlatform - def _generate_class(self, layout: PassLayout, context, fields): + def _generate_class(self): output_class = EventTicketClass( - context["issuerName"], - context["classId"], + self.event.organizer.name, + get_class_id(self.event), MultipleDevicesAndHoldersAllowedStatus.multipleHolders, # TODO: Make configurable - context["eventName"], + self.event.name, ReviewStatus.underReview, - context["locale"], + self.event.settings.locale, ) output_class.homepage_uri( - context["homepageUrl"], - get_translated_string("Website", context["locale"]), - get_translated_dict("Website", context["locales"]), + eventreverse_absolute(self.event, "presale:event.index"), + get_translated_string("Website", self.event.settings.locale), + get_translated_dict("Website", self.event.settings.locales), ) - if context.get("webhookUrl"): # TODO: enforce that it exists - output_class.callback_url(context["webhookUrl"]) + # TODO: callback url + # output_class.callback_url(eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",)) # TODO: move to pass settings or set defaults # if (event.settings.get('ticketoutput_googlepaypasses_latitude') @@ -200,53 +175,18 @@ class GoogleWalletStyle(PassStyle): # return self._comms().put_item(ClassType.eventTicketClass, class_name, output_class) return output_class - def _generate_object(self, layout: PassLayout, context, fields): - class_id = context["classId"] - object_id = context["objectId"] + def _generate_object(self, op: OrderPosition, class_id: str): output_object = EventTicketObject( - object_id, class_id, ObjectState.active, context["locale"] + get_object_id(op), class_id, ObjectState.active, self.event.settings.locale ) - - # output_object.barcode(Barcode.qrCode, op.secret, op.secret) - - # output_object.reservation_info("%s-%s" % (op.order.event.slug, op.order.code)) - # output_object.ticket_holder_name(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else '')) - # output_object.ticket_number(op.secret) - # 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) - - # if op.order.event.seating_plan_id is not None: - # if op.seat: - # output_object.seat( - # get_translated_dict( - # _(str(op.seat)), - # op.order.event.settings.get('locales') - # ) - # ) - # else: - # output_object.seat( - # get_translated_dict( - # _('General admission'), - # op.order.event.settings.get('locales') - # ) - # ) - - # return self._comms().put_item(ObjectType.eventTicketObject, object_name, output_object) return output_object - def generate(self, layout, context): - comms = Comms(context["credentials"]) - fields = self.get_pass_fields(layout, context) + def generate(self, op): + self.op = op + comms = Comms(self.event.settings.get("wallet_google_credentials").read()) - class_object = self._generate_class(layout, context, fields) - ticket_object = self._generate_object(layout, context, fields) + class_object = self._generate_class() + ticket_object = self._generate_object(op, class_id=class_object['id']) # TODO: privacy screen class_object = comms.put_item(ClassType.eventTicketClass, class_object['id'], class_object) @@ -327,8 +267,8 @@ class GoogleWalletEventTicket(GoogleWalletStyle): ] ] - def _generate_object(self, layout: PassLayout, context, fields): - output_object = super()._generate_object(layout, context, fields) + def _generate_object(self, op: OrderPosition, class_id: str): + output_object = super()._generate_object(op, class_id) if fields["code"]: output_object.barcode( diff --git a/src/pretix/plugins/wallet/views.py b/src/pretix/plugins/wallet/views.py index d76254de54..114a51a2ef 100644 --- a/src/pretix/plugins/wallet/views.py +++ b/src/pretix/plugins/wallet/views.py @@ -23,7 +23,6 @@ from .styles import ( AVAILABLE_STYLES, AVAILABLE_PLATFORMS, AVAILABLE_STYLES_DICT, - PassLayout, ) from django.contrib import messages from django.contrib.staticfiles import finders @@ -172,9 +171,8 @@ class LayoutPreviewView(EventPermissionRequiredMixin, View): language(request.event.settings.locale, request.event.settings.region), ): p = get_preview_position(request.event) - layout = PassLayout(style=style, layout=layout) - context = {"placeholders": get_wallet_placeholders(event)} - layout.validate(context=context) + layout = style(event=event, layout=layout) + layout.validate() fname, mimet, data = platform.generate(layout, p) resp = HttpResponse(data, content_type=mimet) diff --git a/src/tests/plugins/wallet/test_wallet.py b/src/tests/plugins/wallet/test_wallet.py index 8be7e7cd95..c97c25c12b 100644 --- a/src/tests/plugins/wallet/test_wallet.py +++ b/src/tests/plugins/wallet/test_wallet.py @@ -4,9 +4,9 @@ from pretix.plugins.wallet.styles.base import ( WalletPlatform, PlaceholderFieldGroup, FieldContentType, - PassLayout, FieldGroupType, FieldEntryType, + FieldGroupDisplay ) from django.utils.translation import gettext as _ import jsonschema @@ -49,50 +49,20 @@ class TicketTestStyle(PassStyle): name=_("Text 2"), content_type=FieldContentType.TEXT, required=False, - labels=False, + display=FieldGroupDisplay.PLAIN, ), PlaceholderFieldGroup( identifier="image1", name=_("Image 1"), content_type=FieldContentType.IMAGE, required=False, - labels=False, + display=FieldGroupDisplay.PLAIN, ), ] - def generate(self, layout, context): - output = f"Generated Pass: {self.name}\n\n" - for group in self.fieldgroups: - if group.identifier in layout["fieldgroups"]: - output += f"Group: {group.name}\n" - if isinstance(group, PredefinedFieldGroup): - output += "PREDEFINED\n" - elif isinstance(group, PlaceholderFieldGroup): - for field in layout["fieldgroups"][group.identifier]["entries"]: - if group.labels: - label = LazyI18nString(field["label"]) - output += f"{label}: " - if field["type"] == FieldEntryType.PLACEHOLDER.value: - placeholder = ( - context.get("placeholders") - .get(group.content_type.value, {}) - .get(field["content"]) - ) - if placeholder: - output += placeholder["evaluate"]( - *context.get("evaluation_context", []) - ) - else: - output += f"UNKNOWN: {field['content']}" - elif field["type"] == FieldEntryType.TEXT.value: - output += str(LazyI18nString(field["content"])) - elif field["type"] == FieldEntryType.IMAGE.value: - output += f"{field['content']}" - output += "\n" - else: - raise ValueError("Unknown field group") - output += "\n" - return output + def generate(self, op): + fields = self.get_pass_fields({}) + return fields @pytest.fixture @@ -105,7 +75,7 @@ def layout_context(): def test_schema_generation_minimal(): - style = MinimalTestStyle() + style = MinimalTestStyle context = {} schema = style.layout_schema(context) assert isinstance(schema, dict) @@ -117,7 +87,7 @@ def test_schema_generation_minimal(): def test_schema_ticket_generation(layout_context): - style = TicketTestStyle() + style = TicketTestStyle schema = style.layout_schema(layout_context) assert isinstance(schema, dict) assert "properties" in schema @@ -194,7 +164,7 @@ def test_schema_ticket_generation(layout_context): ], ) def test_schema_ticket_valid(layout_context, layout): - style = TicketTestStyle() + style = TicketTestStyle schema = style.layout_schema(layout_context) jsonschema.validate(layout, schema) @@ -287,7 +257,7 @@ def test_schema_ticket_valid(layout_context, layout): ], ) def test_schema_ticket_invalid(layout_context, layout): - style = TicketTestStyle() + style = TicketTestStyle schema = style.layout_schema(layout_context) with pytest.raises(jsonschema.ValidationError): @@ -295,7 +265,7 @@ def test_schema_ticket_invalid(layout_context, layout): def test_style_representation(): - style = TicketTestStyle() + style = TicketTestStyle style_dict = style.asdict() assert style_dict["platform"] == "test_platform" assert style_dict["identifier"] == "test_ticket" @@ -309,7 +279,7 @@ def test_style_representation(): def test_layout_generate(layout_context): - style = TicketTestStyle() + style = TicketTestStyle layout = { "fieldgroups": { "text1": { @@ -325,8 +295,8 @@ def test_layout_generate(layout_context): } } } - - pass_layout = PassLayout(style, layout) + # TODO: create event and pass here + pass_layout = style(event=None, layout=layout) generated_pass = pass_layout.generate(layout_context) assert (