mirror of
https://github.com/pretix/pretix.git
synced 2026-06-11 01:25:13 +00:00
WIP
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -20,10 +20,12 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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):
|
||||
|
||||
101
src/pretix/plugins/wallet/forms.py
Normal file
101
src/pretix/plugins/wallet/forms.py
Normal 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
|
||||
@@ -10,7 +10,7 @@ class Migration(migrations.Migration):
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0300_alter_customer_locale_alter_user_locale"),
|
||||
("pretixbase", "0297_outgoingmail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
|
||||
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -20,12 +20,16 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
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")
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
# <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
|
||||
|
||||
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()
|
||||
|
||||
@@ -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 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)})
|
||||
}
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
@@ -117,5 +142,7 @@ const platformChoices = computed(() => {
|
||||
pre
|
||||
code {{ wallet_layout }}
|
||||
.form-group.submit-group
|
||||
button.btn.btn-lg.btn-default(type="button" @click="openPreview") Preview
|
||||
button.btn.btn-primary.btn-save(type="submit") Submit
|
||||
|
||||
</template>
|
||||
|
||||
@@ -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"]
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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],
|
||||
|
||||
@@ -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 %}
|
||||
@@ -5,7 +5,7 @@
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Wallet layouts" %}</h1>
|
||||
{% if layouts|length == 0 %}
|
||||
{% if object_list|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
@@ -30,7 +30,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in layouts %}
|
||||
{% for l in object_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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<organizer>[^/]+)/(?P<event>[^/]+)/wallet/edit/(?P<layout>[^/]+)/$',
|
||||
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
|
||||
LayoutEditorView.as_view(), name='default'),
|
||||
LayoutSetDefault.as_view(), name='default'),
|
||||
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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
Reference in New Issue
Block a user