This commit is contained in:
Kara Engelhardt
2026-07-29 10:35:18 +02:00
parent 8bbf90a5d0
commit bf4cbc3922
8 changed files with 133 additions and 241 deletions
+3 -4
View File
@@ -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
+13 -3
View File
@@ -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)
+7 -8
View File
@@ -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"]
+36 -42
View File
@@ -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):
+39 -57
View File
@@ -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)
def generate(self, op: OrderPosition):
raise NotImplementedError()
+19 -79
View File
@@ -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(
+2 -4
View File
@@ -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)
+14 -44
View File
@@ -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"<IMG>{field['content']}</IMG>"
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 (