diff --git a/src/pretix/base/services/tickets.py b/src/pretix/base/services/tickets.py index fc50e79682..ec5955fa1f 100644 --- a/src/pretix/base/services/tickets.py +++ b/src/pretix/base/services/tickets.py @@ -38,6 +38,7 @@ 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__) @@ -90,36 +91,43 @@ def generate(model: str, pk: int, provider: str): 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')) -def preview(event: int, provider: str): + 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) with rolledback_transaction(), language(event.settings.locale, event.settings.region): - 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()} - 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")) + p = get_preview_position(event) responses = register_ticket_outputs.send(event) for receiver, response in responses: prov = response(event) if prov.identifier == provider: - return prov.generate(p) + return prov.generate(p, **provider_arguments) def get_tickets_for_order(order, base_position=None): diff --git a/src/pretix/helpers/models.py b/src/pretix/helpers/models.py index 8e38141969..a067a02a39 100644 --- a/src/pretix/helpers/models.py +++ b/src/pretix/helpers/models.py @@ -20,10 +20,12 @@ # . # import copy +import typing from django.core.files import File from django.db import models +T = typing.TypeVar('T', bound=models.Model) class Thumbnail(models.Model): source = models.CharField(max_length=255) @@ -45,6 +47,13 @@ def modelcopy(obj: models.Model, **kwargs): setattr(n, f.name, copy.deepcopy(val)) 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 def flatten_choices(choices): diff --git a/src/pretix/plugins/wallet/forms.py b/src/pretix/plugins/wallet/forms.py new file mode 100644 index 0000000000..eb86538445 --- /dev/null +++ b/src/pretix/plugins/wallet/forms.py @@ -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 diff --git a/src/pretix/plugins/wallet/migrations/0001_initial.py b/src/pretix/plugins/wallet/migrations/0001_initial.py index a60822286c..33e161c207 100644 --- a/src/pretix/plugins/wallet/migrations/0001_initial.py +++ b/src/pretix/plugins/wallet/migrations/0001_initial.py @@ -10,7 +10,7 @@ class Migration(migrations.Migration): initial = True dependencies = [ - ("pretixbase", "0300_alter_customer_locale_alter_user_locale"), + ("pretixbase", "0297_outgoingmail"), ] operations = [ diff --git a/src/pretix/plugins/wallet/migrations/0002_alter_walletlayoutitem_unique_together_and_more.py b/src/pretix/plugins/wallet/migrations/0002_alter_walletlayoutitem_unique_together_and_more.py new file mode 100644 index 0000000000..25635e8022 --- /dev/null +++ b/src/pretix/plugins/wallet/migrations/0002_alter_walletlayoutitem_unique_together_and_more.py @@ -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", + ), + ), + ] diff --git a/src/pretix/plugins/wallet/models.py b/src/pretix/plugins/wallet/models.py index b91dba6306..a125f6a518 100644 --- a/src/pretix/plugins/wallet/models.py +++ b/src/pretix/plugins/wallet/models.py @@ -20,12 +20,16 @@ # . # 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 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 + class WalletLayout(LoggedModel): event = models.ForeignKey( @@ -37,9 +41,18 @@ class WalletLayout(LoggedModel): max_length=190, verbose_name=_('Name') ) + default = models.BooleanField( + verbose_name=_('Default'), + default=False, + ) objects = ScopedManager(organizer='event__organizer') + class Meta: + constraints = [ + constraints.UniqueConstraint("event", condition=Q(default=True), name="one_default_wallet_per_event") + ] + class WalletPlatformLayout(LoggedModel): parent = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name="platform_layouts") @@ -53,15 +66,16 @@ class WalletPlatformLayout(LoggedModel): class Meta: 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): - 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) layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments') - class Meta: - unique_together = (('item', 'layout'),) - def clean(self): if self.item.event != self.layout.event: raise ValidationError("cannot bind layout to item of different event") diff --git a/src/pretix/plugins/wallet/signals.py b/src/pretix/plugins/wallet/signals.py index f67f2a6130..b5facb8e77 100644 --- a/src/pretix/plugins/wallet/signals.py +++ b/src/pretix/plugins/wallet/signals.py @@ -20,7 +20,7 @@ # . # -from pretix.base.signals import register_ticket_outputs +from pretix.base.signals import register_ticket_outputs, register_global_settings from .ticketoutput import OUTPUTS def connect_signals(): @@ -30,6 +30,8 @@ def connect_signals(): def register(sender, **kwargs): return o 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() diff --git a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue index 72cd29b1fa..f29a3e88c7 100644 --- a/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue +++ b/src/pretix/plugins/wallet/static/pretixplugins/wallet/components/app.vue @@ -62,6 +62,27 @@ function saveLayout(e: SubmitEvent) { }); } +function openForm(url: string, data: Record) { + + 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 currentLayout = 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])] }); +function openPreview(e: SubmitEvent) { + e.preventDefault(); + openForm("../../preview/", {"csrfmiddlewaretoken": CSRF_TOKEN, "platform": currentPlatform.value, "style": platformLayout.value.style, "layout": JSON.stringify(platformLayout.value.layout)}) +} diff --git a/src/pretix/plugins/wallet/styles/__init__.py b/src/pretix/plugins/wallet/styles/__init__.py index 2ab98897fb..c39651179a 100644 --- a/src/pretix/plugins/wallet/styles/__init__.py +++ b/src/pretix/plugins/wallet/styles/__init__.py @@ -15,4 +15,13 @@ AVAILABLE_STYLES_DICT = { 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"] diff --git a/src/pretix/plugins/wallet/styles/apple.py b/src/pretix/plugins/wallet/styles/apple.py index da8c178805..e034238f70 100644 --- a/src/pretix/plugins/wallet/styles/apple.py +++ b/src/pretix/plugins/wallet/styles/apple.py @@ -7,6 +7,7 @@ from .base import ( WalletPlatform, PassStyle, PlaceholderFieldEntry, + PassLayout, ) from django.utils.translation import gettext as _ from i18nfield.strings import LazyI18nString @@ -14,15 +15,56 @@ import io import hashlib import zipfile import cryptography +import cryptography.x509 import cryptography.hazmat.primitives.serialization.pkcs7 import json from django.contrib.staticfiles import finders - +from pretix.base.models import OrderPosition +from django.utils.encoding import force_bytes class ApplePlatform(WalletPlatform): identifier = "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: # mapping string in default event locale -> LazyI18nString @@ -58,13 +100,13 @@ class StringResource: class SignedZipFile: """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( - 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( - key, password + force_bytes(key), force_bytes(password) if password else None ) self.password = password @@ -109,8 +151,6 @@ class SignedZipFile: class AppleWalletStyle(PassStyle): - platform = ApplePlatform - def pass_content(self, fields, strings): raise NotImplementedError() @@ -145,17 +185,17 @@ class AppleWalletStyle(PassStyle): context["key"], context["password"], ) - strings = StringResource(locales=context['locales']) + strings = StringResource(locales=context["locales"]) pass_json = self.generate_pass_json(fields, context, strings) print(pass_json) - if fields['logo']: - logo = fields['logo'][0]['value'] + if fields["logo"]: + logo = fields["logo"][0]["value"] else: logo = open(finders.find("pretix_passbook/logo.png"), "rb") - if fields['icon']: - icon = fields['icon'][0]['value'] + if fields["icon"]: + icon = fields["icon"][0]["value"] else: 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." TextFieldGroup( identifier="headers", name=_("Header"), max_entries=3 - ), # TODO: header image + ), TextFieldGroup(identifier="auxillary", name=_("Auxillary"), max_entries=4), TextFieldGroup(identifier="back", name=_("Back")), ] @@ -222,24 +262,32 @@ class AppleWalletEventTicket(AppleWalletStyle): def convert_fields(self, strings, fields, prefix): converted = [] - for i,f in enumerate(fields): + for i, f in enumerate(fields): converted_field = {**f, "key": f"{prefix}-{i}"} - if "label" in converted_field and isinstance(converted_field['label'], LazyI18nString): - strings.add_entry(f"{prefix}-{i}-label", converted_field['label']) - converted_field['label'] = f"{prefix}-{i}-label" + if "label" in converted_field and isinstance( + converted_field["label"], LazyI18nString + ): + strings.add_entry(f"{prefix}-{i}-label", converted_field["label"]) + converted_field["label"] = f"{prefix}-{i}-label" - if isinstance(converted_field['value'], LazyI18nString): - strings.add_entry(f"{prefix}-{i}-value", converted_field['value']) - converted_field['value'] = f"{prefix}-{i}-value" + if isinstance(converted_field["value"], LazyI18nString): + strings.add_entry(f"{prefix}-{i}-value", converted_field["value"]) + converted_field["value"] = f"{prefix}-{i}-value" converted.append(converted_field) return converted def pass_content(self, fields, strings): return { "eventTicket": { - "primaryFields": self.convert_fields(strings, fields['primary'], 'primary'), - "secondaryFields": self.convert_fields(strings, fields['secondary'], 'secondary'), - "auxillaryFields": self.convert_fields(strings, fields['auxillary'], 'auxillary'), - "backFields": self.convert_fields(strings, fields['back'], 'back'), + "primaryFields": self.convert_fields( + strings, fields["primary"], "primary" + ), + "secondaryFields": self.convert_fields( + strings, fields["secondary"], "secondary" + ), + "auxillaryFields": self.convert_fields( + strings, fields["auxillary"], "auxillary" + ), + "backFields": self.convert_fields(strings, fields["back"], "back"), } } diff --git a/src/pretix/plugins/wallet/styles/base.py b/src/pretix/plugins/wallet/styles/base.py index 99b324fdb2..e29685164c 100644 --- a/src/pretix/plugins/wallet/styles/base.py +++ b/src/pretix/plugins/wallet/styles/base.py @@ -222,7 +222,6 @@ class ImageFieldGroup(PlaceholderFieldGroup): class PassStyle: - platform: type[WalletPlatform] identifier: str # unique within platform 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 @@ -232,7 +231,6 @@ class PassStyle: def asdict(self): return { - "platform": self.platform.identifier, "identifier": self.identifier, "name": self.name, "fieldgroups": [x.asdict() for x in self.fieldgroups], diff --git a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/delete.html b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/delete.html new file mode 100644 index 0000000000..3ebb5a5364 --- /dev/null +++ b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/delete.html @@ -0,0 +1,19 @@ +{% extends "pretixcontrol/event/base.html" %} +{% load i18n %} +{% load bootstrap3 %} +{% block title %}{% trans "Wallet layout" %}{% endblock %} +{% block content %} +

{% trans "Wallet layout" %}

+
+ {% csrf_token %} +

{% blocktrans with name=object.name %}Are you sure you want to delete "{{ name }}"?{% endblocktrans %}

+
+ + {% trans "Cancel" %} + + +
+
+{% endblock %} diff --git a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html index 4276acf17c..cb351e9cab 100644 --- a/src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html +++ b/src/pretix/plugins/wallet/templates/pretixplugins/wallet/layout_list.html @@ -5,7 +5,7 @@ {% block title %}{% trans "Wallet layouts" %}{% endblock %} {% block content %}

{% trans "Wallet layouts" %}

- {% if layouts|length == 0 %} + {% if object_list|length == 0 %}

{% blocktrans trimmed %} @@ -30,7 +30,7 @@ - {% for l in layouts %} + {% for l in object_list %} {% if "can_change_event_settings" in request.eventpermset %} diff --git a/src/pretix/plugins/wallet/ticketoutput.py b/src/pretix/plugins/wallet/ticketoutput.py index d532fa2ee1..4bc3937b0b 100644 --- a/src/pretix/plugins/wallet/ticketoutput.py +++ b/src/pretix/plugins/wallet/ticketoutput.py @@ -25,14 +25,17 @@ from pretix.base.ticketoutput import BaseTicketOutput from pretix.base.models import Event from pretix.base.settings import SettingsSandbox from django.template.loader import render_to_string - +from django.shortcuts import get_object_or_404 from .styles import AVAILABLE_STYLES_DICT +from .styles.base import PassLayout, WalletPlatform from .styles.apple import ApplePlatform from .styles.google import GooglePlatform - +from collections import OrderedDict from .models import WalletLayout 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") @@ -55,6 +58,7 @@ class WalletSettingsHolder(BaseTicketOutput): class WalletOutput(BaseTicketOutput): settings_form_fields = [] + platform: WalletPlatform def __init__(self, event: Event): super().__init__(event) @@ -62,6 +66,14 @@ class WalletOutput(BaseTicketOutput): "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): identifier = "wallet_google" @@ -76,61 +88,75 @@ class AppleWalletTicketOutput(WalletOutput): download_button_text = "Add to Apple Wallet" platform = ApplePlatform - def generate(self, op): - order = op.order - event = order.event - filename = "{}-{}.pkpass".format(order.event.slug, order.code) - - # layout = self.override_layout_signal.send_chained( - # order.event, 'layout', orderposition=op, layout=self.layout_map.get( - # (op.item_id, self.override_channel or order.sales_channel.identifier), - # self.layout_map.get( - # (op.item_id, 'web'), - # self.default_layout - # ) - # ) - # ) - layout = WalletLayout.objects.get(pk=1) - platform_layout = layout.platform_layouts.get(platform=self.platform.identifier) - - 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, + def get_global_settings(sender, **kwargs): + return OrderedDict( + [ + ( + "wallet_apple_team_id", + forms.CharField( + label=_("Apple Wallet Pass team ID"), + required=False, + ), + ), + ( + "wallet_apple_pass_type_id", + forms.CharField( + label=_("Apple Wallet Pass type"), + required=False, + ), + ), + ( + "wallet_apple_certificate", + CertificateFileField( + label=_("Apple Wallet Pass certificate file"), + required=False, + ), + ), + ( + "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] diff --git a/src/pretix/plugins/wallet/urls.py b/src/pretix/plugins/wallet/urls.py index 74de7d8aac..f5728a4044 100644 --- a/src/pretix/plugins/wallet/urls.py +++ b/src/pretix/plugins/wallet/urls.py @@ -25,7 +25,10 @@ from pretix.api.urls import event_router from .views import ( LayoutEditorView, LayoutCreateView, - LayoutListView + LayoutListView, + LayoutPreviewView, + LayoutSetDefault, + LayoutDelete ) from .api import WalletLayoutViewSet @@ -36,10 +39,12 @@ urlpatterns = [ LayoutCreateView.as_view(), name='add'), re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/wallet/edit/(?P[^/]+)/$', LayoutEditorView.as_view(), name='edit'), + re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/wallet/preview/$', + LayoutPreviewView.as_view(), name='preview'), re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/wallet/default/(?P[^/]+)/$', # TODO - LayoutEditorView.as_view(), name='default'), + LayoutSetDefault.as_view(), name='default'), re_path(r'^control/event/(?P[^/]+)/(?P[^/]+)/wallet/delete/(?P[^/]+)/$', # TODO - LayoutEditorView.as_view(), name='delete'), + LayoutDelete.as_view(), name='delete'), ] event_router.register('walletlayouts', WalletLayoutViewSet) diff --git a/src/pretix/plugins/wallet/views.py b/src/pretix/plugins/wallet/views.py index e9ff8090e1..f74b74e1c8 100644 --- a/src/pretix/plugins/wallet/views.py +++ b/src/pretix/plugins/wallet/views.py @@ -1,18 +1,28 @@ +import copy import json from typing import Any +from django.db import transaction 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.http import HttpResponse, HttpResponseRedirect 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.services.tickets import get_preview_position from pretix.control.permissions import EventPermissionRequiredMixin 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 .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.utils.functional import cached_property def get_layout_variables(event): return { @@ -32,22 +42,25 @@ def get_editor_variables(event): for t, vs in get_layout_variables(event).items() } - -class LayoutListView(EventPermissionRequiredMixin, ListView): +class WalletLayoutMixin: model = WalletLayout - permission = "can_change_event_settings" - template_name = "pretixplugins/wallet/layout_list.html" + permission = "event.settings.general:write" + pk_url_kwarg = "layout" context_object_name = "layouts" def get_queryset(self): 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" - model = WalletLayout - permission = "event.settings.general:write" - pk_url_kwarg = "layout" def get_context_data(self, **kwargs) -> dict[str, Any]: context = super().get_context_data(**kwargs) @@ -85,14 +98,26 @@ class WalletLayoutCreateForm(forms.ModelForm): return super().save(*args, **kwargs) -class LayoutCreateView(CreateView): +class LayoutCreateView(WalletLayoutMixin, EventPermissionRequiredMixin, CreateView): template_name = "pretixplugins/wallet/create.html" form_class = WalletLayoutCreateForm 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]: kwargs = super().get_form_kwargs() kwargs["event"] = self.request.event + + if self.copy_from: + kwargs['instance'] = modelclone(self.copy_from, default=False) + kwargs.setdefault('initial', {}) + return kwargs def get_success_url(self) -> str: @@ -104,3 +129,87 @@ class LayoutCreateView(CreateView): "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())