This commit is contained in:
Kara Engelhardt
2026-06-10 18:11:49 +02:00
parent 638c78363c
commit 265a464b78
16 changed files with 544 additions and 128 deletions

View File

@@ -38,6 +38,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction from pretix.helpers.database import rolledback_transaction
from django.db import transaction
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -90,36 +91,43 @@ def generate(model: str, pk: int, provider: str):
class DummyRollbackException(Exception): class DummyRollbackException(Exception):
pass 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")
def preview(event: int, provider: str): 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'))
from pretix.base.models import Order
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu',
locale=event.settings.locale,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
expires=now(), code="PREVIEW1234", total=119)
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)
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)
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) event = Event.objects.get(id=event)
with rolledback_transaction(), language(event.settings.locale, event.settings.region): with rolledback_transaction(), language(event.settings.locale, event.settings.region):
item = event.items.create(name=_("Sample product"), default_price=Decimal('42.23'), p = get_preview_position(event)
description=_("Sample product description"))
item2 = event.items.create(name=_("Sample workshop"), default_price=Decimal('23.40'))
from pretix.base.models import Order
order = event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu',
locale=event.settings.locale,
sales_channel=event.organizer.sales_channels.get(identifier="web"),
expires=now(), code="PREVIEW1234", total=119)
scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
sample = {k: str(v) for k, v in scheme['sample'].items()}
p = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price)
s = event.subevents.first()
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
order.positions.create(item=item2, attendee_name_parts=sample, price=item.default_price, addon_to=p, subevent=s)
InvoiceAddress.objects.create(order=order, name_parts=sample, company=_("Sample company"))
responses = register_ticket_outputs.send(event) responses = register_ticket_outputs.send(event)
for receiver, response in responses: for receiver, response in responses:
prov = response(event) prov = response(event)
if prov.identifier == provider: if prov.identifier == provider:
return prov.generate(p) return prov.generate(p, **provider_arguments)
def get_tickets_for_order(order, base_position=None): def get_tickets_for_order(order, base_position=None):

View File

@@ -20,10 +20,12 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import copy import copy
import typing
from django.core.files import File from django.core.files import File
from django.db import models from django.db import models
T = typing.TypeVar('T', bound=models.Model)
class Thumbnail(models.Model): class Thumbnail(models.Model):
source = models.CharField(max_length=255) source = models.CharField(max_length=255)
@@ -45,6 +47,13 @@ def modelcopy(obj: models.Model, **kwargs):
setattr(n, f.name, copy.deepcopy(val)) setattr(n, f.name, copy.deepcopy(val))
return n return n
def modelclone(obj: T, **kwargs) -> T:
new = copy.copy(obj)
new.pk = None
new._state.adding = True
for k,v in kwargs.items():
setattr(new, k, v)
return new
# django 5 contains this in django.utils.choices.flatten_choices # django 5 contains this in django.utils.choices.flatten_choices
def flatten_choices(choices): def flatten_choices(choices):

View File

@@ -0,0 +1,101 @@
import logging
import re
import subprocess
import tempfile
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.utils.translation import gettext_lazy as _
from pretix.control.forms import ClearableBasenameFileInput
from django.core.files import File
logger = logging.getLogger(__name__)
def validate_rsa_privkey(value: File):
value = value.read().strip()
if isinstance(value, bytes):
value = value.decode()
if not value:
return
if not re.match(r"^-----BEGIN( (RSA |ENCRYPTED )?PRIVATE KEY-----).*-----END\1$", value, re.DOTALL):
raise ValidationError(
_(
"This does not look like an RSA private key in PEM format (it misses the correct begin or end signifiers)"
),
)
class CertificateFileField(forms.FileField):
widget = ClearableBasenameFileInput
def clean(self, value, *args, **kwargs):
value = super().clean(value, *args, **kwargs)
if isinstance(value, UploadedFile):
value.open("rb")
value.seek(0)
content = value.read()
if (
content.startswith(b"-----BEGIN CERTIFICATE-----")
and b"-----BEGIN CERTIFICATE-----" in content
):
return SimpleUploadedFile("cert.pem", content, "text/plain")
openssl_cmd = [
"openssl",
"x509",
"-inform",
"DER",
"-outform",
"PEM",
]
process = subprocess.Popen(
openssl_cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
)
process.stdin.write(content)
pem, error = process.communicate()
if process.returncode != 0:
logger.info("Trying to convert a DER to PEM failed: {}".format(error))
raise ValidationError(
_(
"This does not look like a X509 certificate in either PEM or DER format"
),
)
return SimpleUploadedFile("cert.pem", pem, "text/plain")
return value
class PNGImageField(forms.FileField):
widget = ClearableBasenameFileInput
def clean(self, value, *args, **kwargs):
value = super().clean(value, *args, **kwargs)
if isinstance(value, UploadedFile):
try:
from PIL import Image
except ImportError:
return value
value.open("rb")
value.seek(0)
try:
with (
Image.open(value, formats=settings.PILLOW_FORMATS_IMAGE) as im,
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
):
im.save(tmpfile.name)
tmpfile.seek(0)
return SimpleUploadedFile(
"picture.png", tmpfile.read(), "image png"
)
except IOError:
logger.exception("Could not convert image to PNG.")
raise ValidationError(
_("The file you uploaded could not be converted to PNG format.")
)
return value

View File

@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
initial = True initial = True
dependencies = [ dependencies = [
("pretixbase", "0300_alter_customer_locale_alter_user_locale"), ("pretixbase", "0297_outgoingmail"),
] ]
operations = [ operations = [

View File

@@ -0,0 +1,41 @@
# Generated by Django 5.2.13 on 2026-06-09 18:04
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("wallet", "0001_initial"),
]
operations = [
migrations.AlterUniqueTogether(
name="walletlayoutitem",
unique_together=set(),
),
migrations.AddField(
model_name="walletlayout",
name="default",
field=models.BooleanField(default=False),
),
migrations.AlterField(
model_name="walletlayoutitem",
name="item",
field=models.OneToOneField(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="walletlayout",
to="pretixbase.item",
),
),
migrations.AddConstraint(
model_name="walletlayout",
constraint=models.UniqueConstraint(
models.F("event"),
condition=models.Q(("default", True)),
name="one_default_wallet_per_event",
),
),
]

View File

@@ -20,12 +20,16 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from django.db import models from django.db import models
from django.db.models import constraints, Q
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
from django_scopes import ScopedManager from django_scopes import ScopedManager
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from pretix.plugins.wallet.styles import get_style
from pretix.plugins.wallet.styles.base import PassLayout
class WalletLayout(LoggedModel): class WalletLayout(LoggedModel):
event = models.ForeignKey( event = models.ForeignKey(
@@ -37,9 +41,18 @@ class WalletLayout(LoggedModel):
max_length=190, max_length=190,
verbose_name=_('Name') verbose_name=_('Name')
) )
default = models.BooleanField(
verbose_name=_('Default'),
default=False,
)
objects = ScopedManager(organizer='event__organizer') objects = ScopedManager(organizer='event__organizer')
class Meta:
constraints = [
constraints.UniqueConstraint("event", condition=Q(default=True), name="one_default_wallet_per_event")
]
class WalletPlatformLayout(LoggedModel): class WalletPlatformLayout(LoggedModel):
parent = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts") parent = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts")
@@ -53,15 +66,16 @@ class WalletPlatformLayout(LoggedModel):
class Meta: class Meta:
unique_together = (('parent', 'platform'),) unique_together = (('parent', 'platform'),)
@property
def pass_layout(self):
style = get_style(self.platform, self.style)
return PassLayout(style=style, layout=self.layout)
class WalletLayoutItem(models.Model): class WalletLayoutItem(models.Model):
item = models.ForeignKey('pretixbase.Item', null=True, blank=True, related_name='walletlayout_assignments', item = models.OneToOneField('pretixbase.Item', null=True, blank=True, related_name='walletlayout',
on_delete=models.CASCADE) on_delete=models.CASCADE)
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments') layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
class Meta:
unique_together = (('item', 'layout'),)
def clean(self): def clean(self):
if self.item.event != self.layout.event: if self.item.event != self.layout.event:
raise ValidationError("cannot bind layout to item of different event") raise ValidationError("cannot bind layout to item of different event")

View File

@@ -20,7 +20,7 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs, register_global_settings
from .ticketoutput import OUTPUTS from .ticketoutput import OUTPUTS
def connect_signals(): def connect_signals():
@@ -30,6 +30,8 @@ def connect_signals():
def register(sender, **kwargs): def register(sender, **kwargs):
return o return o
return register return register
register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"output_{output.identifier}") register_ticket_outputs.connect(get_register_func(output), dispatch_uid=f"wallet_output_{output.identifier}")
if hasattr(output, "get_global_settings"):
register_global_settings.connect(output.get_global_settings, dispatch_uid=f"wallet_global_settings_{output.identifier}")
connect_signals() connect_signals()

View File

@@ -62,6 +62,27 @@ function saveLayout(e: SubmitEvent) {
}); });
} }
function openForm(url: string, data: Record<string, string>) {
let form = document.createElement("form");
form.target = "_blank";
form.method = "POST";
form.action = url;
form.style.display = "none";
for (var key in data) {
var input = document.createElement("input");
input.type = "hidden";
input.name = key;
input.value = data[key];
form.appendChild(input);
}
document.body.appendChild(form);
form.submit();
document.body.removeChild(form);
}
const currentPlatform = ref(PLATFORMS[0].identifier); const currentPlatform = ref(PLATFORMS[0].identifier);
const currentLayout = computed(() => ({})); const currentLayout = computed(() => ({}));
const platformStyles = computed(() => { const platformStyles = computed(() => {
@@ -85,6 +106,10 @@ const platformChoices = computed(() => {
return [[null, "Do not generate pass"], ...Object.values(platformStyles.value).map(x => [x.identifier, x.name])] return [[null, "Do not generate pass"], ...Object.values(platformStyles.value).map(x => [x.identifier, x.name])]
}); });
function openPreview(e: SubmitEvent) {
e.preventDefault();
openForm("../../preview/", {"csrfmiddlewaretoken": CSRF_TOKEN, "platform": currentPlatform.value, "style": platformLayout.value.style, "layout": JSON.stringify(platformLayout.value.layout)})
}
</script> </script>
<template lang="pug"> <template lang="pug">
@@ -117,5 +142,7 @@ const platformChoices = computed(() => {
pre pre
code {{ wallet_layout }} code {{ wallet_layout }}
.form-group.submit-group .form-group.submit-group
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
button.btn.btn-primary.btn-save(type="submit") Submit button.btn.btn-primary.btn-save(type="submit") Submit
</template> </template>

View File

@@ -15,4 +15,13 @@ AVAILABLE_STYLES_DICT = {
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items() plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
} }
# TODO? move to models?
def get_platform(identifier: str):
for platform in AVAILABLE_PLATFORMS:
if platform.identifier == identifier:
return platform
def get_style(platform: str, identifier: str):
return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier)
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"] __all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassLayout"]

View File

@@ -7,6 +7,7 @@ from .base import (
WalletPlatform, WalletPlatform,
PassStyle, PassStyle,
PlaceholderFieldEntry, PlaceholderFieldEntry,
PassLayout,
) )
from django.utils.translation import gettext as _ from django.utils.translation import gettext as _
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
@@ -14,15 +15,56 @@ import io
import hashlib import hashlib
import zipfile import zipfile
import cryptography import cryptography
import cryptography.x509
import cryptography.hazmat.primitives.serialization.pkcs7 import cryptography.hazmat.primitives.serialization.pkcs7
import json import json
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from pretix.base.models import OrderPosition
from django.utils.encoding import force_bytes
class ApplePlatform(WalletPlatform): class ApplePlatform(WalletPlatform):
identifier = "apple" identifier = "apple"
name = _("Apple") name = _("Apple")
@classmethod
def generate(cls, layout: PassLayout, op: OrderPosition):
from ..views import get_layout_variables
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,
)
breakpoint()
context = {
"placeholders": get_layout_variables(op.order.event),
"evaluation_context": [op, order, order.event],
"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,
"locales": event.settings.locales,
}
data = layout.generate(context)
return filename, "application/vnd.apple.pkpass", data
class StringResource: class StringResource:
# mapping string in default event locale -> LazyI18nString # mapping string in default event locale -> LazyI18nString
@@ -58,13 +100,13 @@ class StringResource:
class SignedZipFile: class SignedZipFile:
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be""" """Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
def __init__(self, ca_certificate, certificate, key, password): def __init__(self, ca_certificate: str | bytes, certificate: str | bytes, key: str | bytes, password):
self.ca_certificate = cryptography.x509.load_pem_x509_certificate( self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
ca_certificate force_bytes(ca_certificate)
) )
self.certificate = cryptography.x509.load_pem_x509_certificate(certificate) self.certificate = cryptography.x509.load_pem_x509_certificate(force_bytes(certificate))
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key( self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
key, password force_bytes(key), force_bytes(password) if password else None
) )
self.password = password self.password = password
@@ -109,8 +151,6 @@ class SignedZipFile:
class AppleWalletStyle(PassStyle): class AppleWalletStyle(PassStyle):
platform = ApplePlatform
def pass_content(self, fields, strings): def pass_content(self, fields, strings):
raise NotImplementedError() raise NotImplementedError()
@@ -145,17 +185,17 @@ class AppleWalletStyle(PassStyle):
context["key"], context["key"],
context["password"], context["password"],
) )
strings = StringResource(locales=context['locales']) strings = StringResource(locales=context["locales"])
pass_json = self.generate_pass_json(fields, context, strings) pass_json = self.generate_pass_json(fields, context, strings)
print(pass_json) print(pass_json)
if fields['logo']: if fields["logo"]:
logo = fields['logo'][0]['value'] logo = fields["logo"][0]["value"]
else: else:
logo = open(finders.find("pretix_passbook/logo.png"), "rb") logo = open(finders.find("pretix_passbook/logo.png"), "rb")
if fields['icon']: if fields["icon"]:
icon = fields['icon'][0]['value'] icon = fields["icon"][0]["value"]
else: else:
icon = open(finders.find("pretix_passbook/icon.png"), "rb") icon = open(finders.find("pretix_passbook/icon.png"), "rb")
@@ -214,7 +254,7 @@ class AppleWalletEventTicket(AppleWalletStyle):
), # 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." ), # 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."
TextFieldGroup( TextFieldGroup(
identifier="headers", name=_("Header"), max_entries=3 identifier="headers", name=_("Header"), max_entries=3
), # TODO: header image ),
TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4), TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4),
TextFieldGroup(identifier="back", name=_("Back")), TextFieldGroup(identifier="back", name=_("Back")),
] ]
@@ -222,24 +262,32 @@ class AppleWalletEventTicket(AppleWalletStyle):
def convert_fields(self, strings, fields, prefix): def convert_fields(self, strings, fields, prefix):
converted = [] converted = []
for i,f in enumerate(fields): for i, f in enumerate(fields):
converted_field = {**f, "key": f"{prefix}-{i}"} converted_field = {**f, "key": f"{prefix}-{i}"}
if "label" in converted_field and isinstance(converted_field['label'], LazyI18nString): if "label" in converted_field and isinstance(
strings.add_entry(f"{prefix}-{i}-label", converted_field['label']) converted_field["label"], LazyI18nString
converted_field['label'] = f"{prefix}-{i}-label" ):
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"], LazyI18nString):
strings.add_entry(f"{prefix}-{i}-value", converted_field['value']) strings.add_entry(f"{prefix}-{i}-value", converted_field["value"])
converted_field['value'] = f"{prefix}-{i}-value" converted_field["value"] = f"{prefix}-{i}-value"
converted.append(converted_field) converted.append(converted_field)
return converted return converted
def pass_content(self, fields, strings): def pass_content(self, fields, strings):
return { return {
"eventTicket": { "eventTicket": {
"primaryFields": self.convert_fields(strings, fields['primary'], 'primary'), "primaryFields": self.convert_fields(
"secondaryFields": self.convert_fields(strings, fields['secondary'], 'secondary'), strings, fields["primary"], "primary"
"auxillaryFields": self.convert_fields(strings, fields['auxillary'], 'auxillary'), ),
"backFields": self.convert_fields(strings, fields['back'], 'back'), "secondaryFields": self.convert_fields(
strings, fields["secondary"], "secondary"
),
"auxillaryFields": self.convert_fields(
strings, fields["auxillary"], "auxillary"
),
"backFields": self.convert_fields(strings, fields["back"], "back"),
} }
} }

View File

@@ -222,7 +222,6 @@ class ImageFieldGroup(PlaceholderFieldGroup):
class PassStyle: class PassStyle:
platform: type[WalletPlatform]
identifier: str # unique within platform identifier: str # unique within platform
name: str name: str
# 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 # 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
@@ -232,7 +231,6 @@ class PassStyle:
def asdict(self): def asdict(self):
return { return {
"platform": self.platform.identifier,
"identifier": self.identifier, "identifier": self.identifier,
"name": self.name, "name": self.name,
"fieldgroups": [x.asdict() for x in self.fieldgroups], "fieldgroups": [x.asdict() for x in self.fieldgroups],

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Wallet layout" %}{% endblock %}
{% block content %}
<h1>{% trans "Wallet layout" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans with name=object.name %}Are you sure you want to delete <strong>"{{ name }}"</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "plugins:wallet:index" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}

View File

@@ -5,7 +5,7 @@
{% block title %}{% trans "Wallet layouts" %}{% endblock %} {% block title %}{% trans "Wallet layouts" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "Wallet layouts" %}</h1> <h1>{% trans "Wallet layouts" %}</h1>
{% if layouts|length == 0 %} {% if object_list|length == 0 %}
<div class="empty-collection"> <div class="empty-collection">
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
@@ -30,7 +30,7 @@
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{% for l in layouts %} {% for l in object_list %}
<tr> <tr>
<td> <td>
{% if "can_change_event_settings" in request.eventpermset %} {% if "can_change_event_settings" in request.eventpermset %}

View File

@@ -25,14 +25,17 @@ from pretix.base.ticketoutput import BaseTicketOutput
from pretix.base.models import Event from pretix.base.models import Event
from pretix.base.settings import SettingsSandbox from pretix.base.settings import SettingsSandbox
from django.template.loader import render_to_string from django.template.loader import render_to_string
from django.shortcuts import get_object_or_404
from .styles import AVAILABLE_STYLES_DICT from .styles import AVAILABLE_STYLES_DICT
from .styles.base import PassLayout, WalletPlatform
from .styles.apple import ApplePlatform from .styles.apple import ApplePlatform
from .styles.google import GooglePlatform from .styles.google import GooglePlatform
from collections import OrderedDict
from .models import WalletLayout from .models import WalletLayout
from .views import get_layout_variables from .views import get_layout_variables
from django import forms
from .forms import CertificateFileField, validate_rsa_privkey
from pretix.control.forms import ClearableBasenameFileInput
logger = logging.getLogger("pretix.plugins.wallet") logger = logging.getLogger("pretix.plugins.wallet")
@@ -55,6 +58,7 @@ class WalletSettingsHolder(BaseTicketOutput):
class WalletOutput(BaseTicketOutput): class WalletOutput(BaseTicketOutput):
settings_form_fields = [] settings_form_fields = []
platform: WalletPlatform
def __init__(self, event: Event): def __init__(self, event: Event):
super().__init__(event) super().__init__(event)
@@ -62,6 +66,14 @@ class WalletOutput(BaseTicketOutput):
"ticketoutput", WalletSettingsHolder.identifier, event "ticketoutput", WalletSettingsHolder.identifier, event
) )
def generate(self, op):
if hasattr(op.item, "walletlayout"):
wallet_layout = op.item.walletlayout
else:
wallet_layout = op.event.wallet_layouts.get(default=True)
platform_layout = get_object_or_404(wallet_layout.platform_layouts, platform=self.platform.identifier)
return self.platform.generate(platform_layout.pass_layout, op)
class GoogleWalletTicketOutput(WalletOutput): class GoogleWalletTicketOutput(WalletOutput):
identifier = "wallet_google" identifier = "wallet_google"
@@ -76,61 +88,75 @@ class AppleWalletTicketOutput(WalletOutput):
download_button_text = "Add to Apple Wallet" download_button_text = "Add to Apple Wallet"
platform = ApplePlatform platform = ApplePlatform
def generate(self, op): def get_global_settings(sender, **kwargs):
order = op.order return OrderedDict(
event = order.event [
filename = "{}-{}.pkpass".format(order.event.slug, order.code) (
"wallet_apple_team_id",
# layout = self.override_layout_signal.send_chained( forms.CharField(
# order.event, 'layout', orderposition=op, layout=self.layout_map.get( label=_("Apple Wallet Pass team ID"),
# (op.item_id, self.override_channel or order.sales_channel.identifier), required=False,
# self.layout_map.get( ),
# (op.item_id, 'web'), ),
# self.default_layout (
# ) "wallet_apple_pass_type_id",
# ) forms.CharField(
# ) label=_("Apple Wallet Pass type"),
layout = WalletLayout.objects.get(pk=1) required=False,
platform_layout = layout.platform_layouts.get(platform=self.platform.identifier) ),
),
ticket = str(op.item.name) (
if op.variation: "wallet_apple_certificate",
ticket += " - " + str(op.variation) CertificateFileField(
label=_("Apple Wallet Pass certificate file"),
serialNumber = "%s-%s-%s-%d" % ( required=False,
order.event.organizer.slug, ),
order.event.slug, ),
order.code, (
op.pk, "wallet_apple_ca_certificate",
CertificateFileField(
label=_("Apple Wallet Pass CA Certificate"),
help_text=_(
"You can download the current CA certificate from apple at "
"https://www.apple.com/certificateauthority/AppleWWDRCAG4.cer"
),
required=False,
),
),
(
"wallet_apple_key",
forms.FileField(
label=_("Apple Wallet Pass secret key"),
required=False,
validators=[validate_rsa_privkey],
widget=ClearableBasenameFileInput
),
),
(
"wallet_apple_key_password",
forms.CharField(
label=_("Apple Wallet Pass key password"),
widget=forms.PasswordInput(render_value=True),
required=False,
help_text=_(
"Optional, only necessary if the key entered above requires a password to use."
),
),
),
]
) )
context = {
"placeholders": get_layout_variables(op.order.event),
"evaluation_context": [op, order, order.event],
"ca_certificate": open(
"/Users/engelhardt/code/tmp/wallet/apple/ca_cert.pem", "rb"
).read(),
"certificate": open(
"/Users/engelhardt/code/tmp/wallet/apple/cert.pem", "rb"
).read(),
"key": open(
"/Users/engelhardt/code/tmp/wallet/apple/secret_key.pem", "rb"
).read(),
"password": None,
"description": _("Ticket for {event} ({product})").format( # TODO: i18n
event=event.name, product=ticket
),
"organizationName": event.organizer.name,
"passTypeIdentifier": "pass.test.test",
"teamIdentifier": "TEST123456",
"serialNumber": serialNumber,
"locales": event.settings.locales
}
data = AVAILABLE_STYLES_DICT[self.platform.identifier][platform_layout.style].generate(
platform_layout.layout, context
)
return filename, "application/vnd.apple.pkpass", data
# settings_hierarkey.add_default("wallet_apple_certificate_file", None, File)
# settings_hierarkey.add_default("wallet_apple_wwdr_certificate_file", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_background", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_background2x", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_background3x", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_icon", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_icon2x", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_icon3x", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_logo", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_logo2x", None, File)
# settings_hierarkey.add_default("ticketoutput_wallet_apple_logo3x", None, File)
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput] OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]

View File

@@ -25,7 +25,10 @@ from pretix.api.urls import event_router
from .views import ( from .views import (
LayoutEditorView, LayoutEditorView,
LayoutCreateView, LayoutCreateView,
LayoutListView LayoutListView,
LayoutPreviewView,
LayoutSetDefault,
LayoutDelete
) )
from .api import WalletLayoutViewSet from .api import WalletLayoutViewSet
@@ -36,10 +39,12 @@ urlpatterns = [
LayoutCreateView.as_view(), name='add'), LayoutCreateView.as_view(), name='add'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$', re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
LayoutEditorView.as_view(), name='edit'), LayoutEditorView.as_view(), name='edit'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/preview/$',
LayoutPreviewView.as_view(), name='preview'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$', # TODO re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/default/(?P<layout>[^/]+)/$', # TODO
LayoutEditorView.as_view(), name='default'), LayoutSetDefault.as_view(), name='default'),
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO
LayoutEditorView.as_view(), name='delete'), LayoutDelete.as_view(), name='delete'),
] ]
event_router.register('walletlayouts', WalletLayoutViewSet) event_router.register('walletlayouts', WalletLayoutViewSet)

View File

@@ -1,18 +1,28 @@
import copy
import json import json
from typing import Any from typing import Any
from django.db import transaction
from django import forms from django import forms
from django.http import Http404 from django.core.exceptions import BadRequest
from django.db.models.query import QuerySet
from django.urls import reverse from django.urls import reverse
from django.http import HttpResponse, HttpResponseRedirect
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import CreateView, DetailView, ListView from django.views.generic import CreateView, DetailView, ListView, DeleteView, View
from pretix.base.i18n import language
from pretix.base.pdf import get_images, get_variables from pretix.base.pdf import get_images, get_variables
from pretix.base.services.tickets import get_preview_position
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
from django.conf import settings from django.conf import settings
from django.shortcuts import redirect
from pretix.helpers.database import rolledback_transaction
from pretix.helpers.models import modelclone
from .models import WalletLayout from .models import WalletLayout
from .styles import AVAILABLE_STYLES, AVAILABLE_PLATFORMS from .styles import AVAILABLE_STYLES, AVAILABLE_PLATFORMS, AVAILABLE_STYLES_DICT, PassLayout
from django.contrib import messages
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.utils.functional import cached_property
def get_layout_variables(event): def get_layout_variables(event):
return { return {
@@ -32,22 +42,25 @@ def get_editor_variables(event):
for t, vs in get_layout_variables(event).items() for t, vs in get_layout_variables(event).items()
} }
class WalletLayoutMixin:
class LayoutListView(EventPermissionRequiredMixin, ListView):
model = WalletLayout model = WalletLayout
permission = "can_change_event_settings" permission = "event.settings.general:write"
template_name = "pretixplugins/wallet/layout_list.html" pk_url_kwarg = "layout"
context_object_name = "layouts" context_object_name = "layouts"
def get_queryset(self): def get_queryset(self):
return self.request.event.wallet_layouts.all() return self.request.event.wallet_layouts.all()
class LayoutListView(WalletLayoutMixin, EventPermissionRequiredMixin, ListView):
template_name = "pretixplugins/wallet/layout_list.html"
class LayoutEditorView(DetailView):
class LayoutDetailView(WalletLayoutMixin, EventPermissionRequiredMixin, DetailView):
pass
class LayoutEditorView(LayoutDetailView):
template_name = "pretixplugins/wallet/edit.html" template_name = "pretixplugins/wallet/edit.html"
model = WalletLayout
permission = "event.settings.general:write"
pk_url_kwarg = "layout"
def get_context_data(self, **kwargs) -> dict[str, Any]: def get_context_data(self, **kwargs) -> dict[str, Any]:
context = super().get_context_data(**kwargs) context = super().get_context_data(**kwargs)
@@ -85,14 +98,26 @@ class WalletLayoutCreateForm(forms.ModelForm):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
class LayoutCreateView(CreateView): class LayoutCreateView(WalletLayoutMixin, EventPermissionRequiredMixin, CreateView):
template_name = "pretixplugins/wallet/create.html" template_name = "pretixplugins/wallet/create.html"
form_class = WalletLayoutCreateForm form_class = WalletLayoutCreateForm
permission = "event.settings.general:write" permission = "event.settings.general:write"
def form_valid(self, form):
self.object = form.save()
if self.copy_from:
for pl in self.copy_from.platform_layouts.all():
modelclone(pl, parent=self.object).save()
return HttpResponseRedirect(self.get_success_url())
def get_form_kwargs(self) -> dict[str, Any]: def get_form_kwargs(self) -> dict[str, Any]:
kwargs = super().get_form_kwargs() kwargs = super().get_form_kwargs()
kwargs["event"] = self.request.event kwargs["event"] = self.request.event
if self.copy_from:
kwargs['instance'] = modelclone(self.copy_from, default=False)
kwargs.setdefault('initial', {})
return kwargs return kwargs
def get_success_url(self) -> str: def get_success_url(self) -> str:
@@ -104,3 +129,87 @@ class LayoutCreateView(CreateView):
"layout": self.object.pk, "layout": self.object.pk,
}, },
) )
@cached_property
def copy_from(self) -> WalletLayout | None:
if self.request.GET.get("copy_from"):
try:
return self.get_queryset().get(pk=self.request.GET.get("copy_from"))
except WalletLayout.DoesNotExist:
pass
class LayoutPreviewView(EventPermissionRequiredMixin, View):
permission = "event.settings.general:write"
def post(self, request, **kwargs):
event = request.event
platform_id = request.POST.get("platform")
style_id = request.POST.get("style")
layout = request.POST.get("layout")
platform = None
for p in AVAILABLE_PLATFORMS:
if p.identifier == platform_id:
platform = p
if not platform:
raise BadRequest("Unknown platform")
if style_id not in AVAILABLE_STYLES_DICT[platform_id]:
raise BadRequest("Unknown style")
style = AVAILABLE_STYLES_DICT[platform_id][style_id]
layout = json.loads(layout)
with rolledback_transaction(), language(request.event.settings.locale, request.event.settings.region):
p = get_preview_position(request.event)
layout = PassLayout(style=style, layout=layout)
context = {"placeholders": get_layout_variables(event)}
layout.validate(context=context)
fname, mimet, data = platform.generate(layout, p)
resp = HttpResponse(data, content_type=mimet)
ftype = fname.split(".")[-1]
resp['Content-Disposition'] = 'attachment; filename="ticket-preview.{}"'.format(ftype)
return resp
class LayoutSetDefault(LayoutDetailView):
@transaction.atomic
def post(self, request, *args, **kwargs):
obj = self.get_object()
request.event.wallet_layouts.exclude(pk=obj.pk).update(default=False)
obj.default = True
obj.save(update_fields=['default'])
messages.success(self.request, _('Your changes have been saved.'))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('plugins:wallet:index', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
class LayoutDelete(WalletLayoutMixin, DeleteView):
template_name = 'pretixplugins/wallet/delete.html'
def get_success_url(self) -> str:
return reverse('plugins:wallet:index', kwargs={
'organizer': self.request.event.organizer.slug,
'event': self.request.event.slug,
})
@transaction.atomic
def form_valid(self, form):
self.object = self.get_object()
self.object.log_action(action='pretix.plugins.wallet.layout.deleted', user=self.request.user)
self.object.delete()
if not self.request.event.wallet_layouts.filter(default=True).exists():
f = self.request.event.wallet_layouts.first()
if f:
f.default = True
f.save(update_fields=['default'])
messages.success(self.request, _('The selected layout been deleted.'))
return redirect(self.get_success_url())