mirror of
https://github.com/pretix/pretix.git
synced 2026-10-03 19:34:41 +00:00
Compare commits
62
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7fc6c2c60d | ||
|
|
98cab28657 | ||
|
|
daec284a54 | ||
|
|
1a0758d7c7 | ||
|
|
8327f65987 | ||
|
|
caf475da8a | ||
|
|
8cd12206d4 | ||
|
|
89cb024ef8 | ||
|
|
c796cf742d | ||
|
|
de957b015e | ||
|
|
a9c3f268bd | ||
|
|
55b90fe88e | ||
|
|
e1bb436add | ||
|
|
9f6896df05 | ||
|
|
8da2dfcf50 | ||
|
|
e4321f204a | ||
|
|
f8de7cc466 | ||
|
|
f5b85a554a | ||
|
|
5b46124dc8 | ||
|
|
5675bcf749 | ||
|
|
62dbb8da15 | ||
|
|
85e3a91546 | ||
|
|
4a89d50386 | ||
|
|
1ae4f37361 | ||
|
|
eaff44e924 | ||
|
|
4837f791db | ||
|
|
9dc083cb88 | ||
|
|
b80c7d68bb | ||
|
|
326992dd6d | ||
|
|
3663754dfb | ||
|
|
77a4929bdf | ||
|
|
af15c7cdb7 | ||
|
|
4b76c4e8d7 | ||
|
|
c8ec5d8367 | ||
|
|
ed06acf580 | ||
|
|
3b998f8acc | ||
|
|
d77a179606 | ||
|
|
29614db1e2 | ||
|
|
365051cc21 | ||
|
|
5c6b0eef6f | ||
|
|
bc0a6b662b | ||
|
|
0bc1aed1a0 | ||
|
|
0eaa5a081c | ||
|
|
688c434c4f | ||
|
|
c0474604a1 | ||
|
|
1741d08c2c | ||
|
|
6bbd808aaa | ||
|
|
aa90c5b3b1 | ||
|
|
bfbabe0e58 | ||
|
|
aa14505d2c | ||
|
|
df69656364 | ||
|
|
82cffd1519 | ||
|
|
7fc527135d | ||
|
|
8e39b67ee9 | ||
|
|
289cbe5bfc | ||
|
|
7143b877c8 | ||
|
|
e2a78100af | ||
|
|
ca77467e48 | ||
|
|
d3cbfcd4da | ||
|
|
3648ef28d7 | ||
|
|
689b99c3aa | ||
|
|
6c40daa863 |
@@ -7,6 +7,7 @@ on:
|
||||
- 'src/pretix/static/pretixpresale/widget/**'
|
||||
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
|
||||
- 'src/pretix/plugins/webcheckin/**'
|
||||
- 'src/pretix/plugins/wallet/**'
|
||||
- 'eslint.config.mjs'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
@@ -16,6 +17,7 @@ on:
|
||||
- 'src/pretix/static/pretixpresale/widget/**'
|
||||
- 'src/pretix/static/pretixcontrol/js/ui/checkinrules/**'
|
||||
- 'src/pretix/plugins/webcheckin/**'
|
||||
- 'src/pretix/plugins/wallet/**'
|
||||
- 'eslint.config.mjs'
|
||||
- 'package.json'
|
||||
- 'package-lock.json'
|
||||
|
||||
Generated
+24
-10
@@ -294,29 +294,43 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/core": {
|
||||
"version": "0.19.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
|
||||
"integrity": "sha512-5DyQ4+1JEUzejeK1JGICcideyfUbGixgS9jNgex5nqkW+cY7WZhxBigmieN5Qnw9ZosSNVC9KQKyb+GUaGyKUA==",
|
||||
"version": "0.19.2",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz",
|
||||
"integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/types": "^0.15.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/node": {
|
||||
"version": "0.16.7",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.7.tgz",
|
||||
"integrity": "sha512-/zUx+yOsIrG4Y43Eh2peDeKCxlRt/gET6aHfaKpuq267qXdYDFViVHfMaLyygZOnl0kGWxFIgsBy8QFuTLUXEQ==",
|
||||
"version": "0.16.8",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz",
|
||||
"integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@humanfs/core": "^0.19.1",
|
||||
"@humanfs/core": "^0.19.2",
|
||||
"@humanfs/types": "^0.15.0",
|
||||
"@humanwhocodes/retry": "^0.4.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanfs/types": {
|
||||
"version": "0.15.0",
|
||||
"resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz",
|
||||
"integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=18.18.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@humanwhocodes/module-importer": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz",
|
||||
@@ -3380,9 +3394,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss-selector-parser": {
|
||||
"version": "7.1.1",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.1.tgz",
|
||||
"integrity": "sha512-orRsuYpJVw8LdAwqqLykBj9ecS5/cRHlI5+nvTo8LcCKmzDmqVORXtOIYEEQuL9D4BxtA1lm5isAqzQZCoQ6Eg==",
|
||||
"version": "7.1.5",
|
||||
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
|
||||
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
|
||||
+2
-2
@@ -18,12 +18,12 @@
|
||||
"doc": "doc"
|
||||
},
|
||||
"scripts": {
|
||||
"dev:control": "vite",
|
||||
"dev:control": "vite --clearScreen=false",
|
||||
"dev:widget": "vite src/pretix/static/pretixpresale/widget",
|
||||
"build": "npm run build:control -s && npm run build:widget -s",
|
||||
"build:control": "vite build",
|
||||
"build:widget": "vite build src/pretix/static/pretixpresale/widget",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin",
|
||||
"lint:eslint": "eslint src/pretix/static/pretixpresale/widget src/pretix/static/pretixcontrol/js/ui/checkinrules src/pretix/plugins/webcheckin src/pretix/plugins/wallet",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"dependencies": {
|
||||
|
||||
+7
-7
@@ -33,14 +33,14 @@ dependencies = [
|
||||
"bleach==6.4.*",
|
||||
"celery==5.6.*",
|
||||
"chardet==5.2.*",
|
||||
"cryptography>=50.0.0",
|
||||
"cryptography>=50.0.1",
|
||||
"css-inline==0.21.*",
|
||||
"defusedcsv>=3.0.0",
|
||||
"dnspython==2.*",
|
||||
"Django[argon2]==5.2.*,>=5.2.17",
|
||||
"django-bootstrap3==26.2",
|
||||
"django-compressor==4.6.0",
|
||||
"django-countries==9.0.*",
|
||||
"django-countries==9.1.*",
|
||||
"django-filter==26.1",
|
||||
"django-formset-js-improved==0.5.0.5",
|
||||
"django-formtools==2.7",
|
||||
@@ -75,7 +75,7 @@ dependencies = [
|
||||
"packaging",
|
||||
"paypalrestsdk==1.13.*",
|
||||
"paypal-checkout-serversdk==1.0.*",
|
||||
"PyJWT==2.13.*",
|
||||
"PyJWT==2.14.*",
|
||||
"phonenumberslite==9.0.*",
|
||||
"Pillow==12.3.*",
|
||||
"pretix-plugin-build",
|
||||
@@ -84,7 +84,7 @@ dependencies = [
|
||||
"pycountry",
|
||||
"pycparser==3.0",
|
||||
"pycryptodome==3.23.*",
|
||||
"pypdf==6.11.*",
|
||||
"pypdf==6.19.*",
|
||||
"python-bidi==0.6.*", # Support for Arabic in reportlab
|
||||
"python-dateutil==2.9.*",
|
||||
"pytz",
|
||||
@@ -94,7 +94,7 @@ dependencies = [
|
||||
"redis==7.4.*",
|
||||
"reportlab==5.0.*",
|
||||
"requests==2.34.*",
|
||||
"sentry-sdk==2.68.*",
|
||||
"sentry-sdk==2.69.*",
|
||||
"sepaxml==2.7.*",
|
||||
"stripe==7.9.*",
|
||||
"text-unidecode==1.*",
|
||||
@@ -112,10 +112,10 @@ dev = [
|
||||
"aiohttp==3.14.*",
|
||||
"coverage",
|
||||
"coveralls",
|
||||
"fakeredis==2.37.*",
|
||||
"fakeredis==2.38.*",
|
||||
"flake8==7.3.*",
|
||||
"freezegun",
|
||||
"isort==8.0.*",
|
||||
"isort==9.0.*",
|
||||
"pep8-naming==0.15.*",
|
||||
"potypo",
|
||||
"pytest-asyncio>=1.4.0",
|
||||
|
||||
@@ -32,6 +32,7 @@ ignore =
|
||||
src/tests/plugins/stripe/*
|
||||
src/tests/plugins/sendmail/*
|
||||
src/tests/plugins/ticketoutputpdf/*
|
||||
src/tests/plugins/wallet/*
|
||||
.*
|
||||
CODE_OF_CONDUCT.md
|
||||
CONTRIBUTING.md
|
||||
|
||||
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
|
||||
'pretix.plugins.returnurl',
|
||||
'pretix.plugins.autocheckin',
|
||||
'pretix.plugins.webcheckin',
|
||||
'pretix.plugins.wallet',
|
||||
'django_countries',
|
||||
'oauth2_provider',
|
||||
'phonenumber_field',
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
from pretix.base.models import (
|
||||
CachedFile
|
||||
)
|
||||
from rest_framework.exceptions import ValidationError
|
||||
from django.core.exceptions import ValidationError as DjangoValidationError
|
||||
from django.conf import settings
|
||||
|
||||
def handle_file_upload(data, user, auth, allowed_types):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, DjangoValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
@@ -63,7 +63,7 @@ from pretix.api.views import RichOrderingFilter
|
||||
from pretix.api.views.order import OrderPositionFilter
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Checkin, CheckinList, Device, Event, Order, OrderPosition,
|
||||
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
|
||||
)
|
||||
from pretix.base.models.orders import PrintLog
|
||||
@@ -75,6 +75,7 @@ from pretix.base.services.checkin import (
|
||||
from pretix.base.services.media import perform_media_exchange
|
||||
from pretix.base.signals import checkin_annulled
|
||||
from pretix.helpers import OF_SELF
|
||||
from pretix.api.helpers import handle_file_upload
|
||||
|
||||
with scopes_disabled():
|
||||
class CheckinListFilter(FilterSet):
|
||||
@@ -328,27 +329,6 @@ with scopes_disabled():
|
||||
)
|
||||
|
||||
|
||||
def _handle_file_upload(data, user, auth):
|
||||
try:
|
||||
cf = CachedFile.objects.get(
|
||||
session_key=f'api-upload-{str(type(user or auth))}-{(user or auth).pk}',
|
||||
file__isnull=False,
|
||||
pk=data[len("file:"):],
|
||||
)
|
||||
except (ValidationError, BaseValidationError, IndexError): # invalid uuid
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
except CachedFile.DoesNotExist:
|
||||
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
|
||||
|
||||
allowed_types = (
|
||||
'image/png', 'image/jpeg', 'image/gif', 'application/pdf'
|
||||
)
|
||||
if cf.type not in allowed_types:
|
||||
raise ValidationError('The submitted file "{fid}" has a file type that is not allowed in this field.'.format(fid=data))
|
||||
if cf.file.size > settings.FILE_UPLOAD_MAX_SIZE_OTHER:
|
||||
raise ValidationError('The submitted file "{fid}" is too large to be used in this field.'.format(fid=data))
|
||||
|
||||
return cf.file
|
||||
|
||||
|
||||
def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_products=False, pdf_data=False, expand=None):
|
||||
@@ -795,7 +775,12 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
|
||||
try:
|
||||
if q.type == Question.TYPE_FILE:
|
||||
if answers_data[str(q.pk)]:
|
||||
given_answers[q] = _handle_file_upload(answers_data[str(q.pk)], user, auth)
|
||||
given_answers[q] = handle_file_upload(
|
||||
answers_data[str(q.pk)],
|
||||
user,
|
||||
auth,
|
||||
allowed_types=('image/png', 'image/jpeg', 'image/gif', 'application/pdf')
|
||||
)
|
||||
else:
|
||||
given_answers[q] = None
|
||||
else:
|
||||
|
||||
@@ -54,6 +54,7 @@ from ...control.forms.filter import get_all_payment_providers
|
||||
from ...helpers import GroupConcat
|
||||
from ...helpers.iter import chunked_iterable
|
||||
from ..exporter import BaseExporter, MultiSheetListExporter
|
||||
from ..invoicing.transmission import get_transmission_types
|
||||
from ..services.export import ExportError
|
||||
from ..services.invoices import invoice_pdf_task
|
||||
from ..signals import (
|
||||
@@ -197,7 +198,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
def iterate_sheet(self, form_data, sheet):
|
||||
_ = gettext
|
||||
if sheet == 'invoices':
|
||||
yield [
|
||||
headers = [
|
||||
_('Invoice number'),
|
||||
_('Date'),
|
||||
_('Order code'),
|
||||
@@ -230,8 +231,18 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
_('Total value (without taxes)'),
|
||||
_('Payment matching IDs'),
|
||||
_('Payment providers'),
|
||||
_('Transmission type'),
|
||||
_('Transmission status'),
|
||||
_('Transmission date'),
|
||||
]
|
||||
|
||||
transmission_types = get_transmission_types()
|
||||
for tt in transmission_types:
|
||||
for c in tt.describe_info_columns():
|
||||
headers.append(str(tt.verbose_name) + ': ' + str(c))
|
||||
|
||||
yield headers
|
||||
|
||||
p_providers = OrderPayment.objects.filter(
|
||||
order=OuterRef('order'),
|
||||
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED,
|
||||
@@ -242,7 +253,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
'm'
|
||||
).order_by()
|
||||
|
||||
base_qs = self.invoices_queryset(form_data)\
|
||||
base_qs = self.invoices_queryset(form_data)
|
||||
|
||||
qs = base_qs.select_related(
|
||||
'order', 'refers'
|
||||
@@ -280,7 +291,7 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
if mid:
|
||||
pmis.append(mid)
|
||||
pmi = '\n'.join(pmis)
|
||||
yield [
|
||||
line = [
|
||||
i.full_invoice_no,
|
||||
date_format(i.date, "SHORT_DATE_FORMAT"),
|
||||
i.order.code,
|
||||
@@ -315,8 +326,20 @@ class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
|
||||
', '.join([
|
||||
str(self.providers.get(p, p)) for p in sorted(set((i.payment_providers or '').split(',')))
|
||||
if p and p != 'free'
|
||||
])
|
||||
]),
|
||||
i.transmission_type_instance.verbose_name,
|
||||
i.get_transmission_status_display(),
|
||||
date_format(i.transmission_date, "SHORT_DATETIME_FORMAT") if i.transmission_date else "",
|
||||
]
|
||||
for tt in transmission_types:
|
||||
if tt.identifier == i.transmission_type:
|
||||
described = dict(tt.describe_info(i.invoice_to_transmission_info, i.invoice_to_country, i.invoice_to_is_business))
|
||||
for c in tt.describe_info_columns():
|
||||
line.append(described.get(c, ""))
|
||||
else:
|
||||
for c in tt.describe_info_columns():
|
||||
line.append("")
|
||||
yield line
|
||||
elif sheet == 'lines':
|
||||
yield [
|
||||
_('Invoice number'),
|
||||
|
||||
@@ -1202,8 +1202,9 @@ class TicketLevelQuestionsForm(BaseQuestionsForm):
|
||||
return field
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
validate_address # local import to prevent impact on startup time
|
||||
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
|
||||
validate_address,
|
||||
)
|
||||
|
||||
d = super().clean()
|
||||
|
||||
@@ -1444,8 +1445,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
self.fields['transmission_type'].widget.attrs['data-trigger-address-info'] = 'on'
|
||||
|
||||
def clean(self):
|
||||
from pretix.base.addressvalidation import \
|
||||
validate_address # local import to prevent impact on startup time
|
||||
from pretix.base.addressvalidation import ( # local import to prevent impact on startup time
|
||||
validate_address,
|
||||
)
|
||||
|
||||
data = self.cleaned_data
|
||||
|
||||
@@ -1499,7 +1501,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
|
||||
"vat_id": _("This field is required.")
|
||||
})
|
||||
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
|
||||
if self.validate_vat_id and self.instance.vat_id_validated and not any(v in self.changed_data for v in ('is_business', 'vat_id', 'country')):
|
||||
pass # Skip re-validation if it is validated
|
||||
elif self.validate_vat_id and vat_id_applicable:
|
||||
try:
|
||||
|
||||
@@ -109,7 +109,7 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
|
||||
|
||||
|
||||
def get_babel_locale():
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
|
||||
# Babel, and therefore also django-phonenumberfield, do not support our custom locales such as de_Informal
|
||||
# Also, this returns best-effort region information for number formatting etc
|
||||
current_language = translation.get_language()
|
||||
current_region = getattr(_active_region, "value", None)
|
||||
|
||||
@@ -107,6 +107,9 @@ class TransmissionType:
|
||||
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
|
||||
return transmission_info
|
||||
|
||||
def describe_info_columns(self):
|
||||
return [f.label for f in self.invoice_address_form_fields.values()]
|
||||
|
||||
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
|
||||
form_data = self.transmission_info_to_form_data(transmission_info)
|
||||
data = []
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
# Generated by Django 5.2.17 on 2026-09-21 11:30
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def fix_unshredded_invoices(apps, _):
|
||||
Invoice = apps.get_model("pretixbase", "Invoice")
|
||||
InvoiceLine = apps.get_model("pretixbase", "InvoiceLine")
|
||||
ignore_fields = (
|
||||
# bool/int fields are not listed and skipped automatically
|
||||
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
|
||||
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
|
||||
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
|
||||
)
|
||||
|
||||
for i in Invoice.objects.filter(shredded=True):
|
||||
for f in Invoice._meta.fields:
|
||||
if f.name in ignore_fields:
|
||||
continue
|
||||
val = getattr(i, f.name, None)
|
||||
if val and isinstance(val, str):
|
||||
setattr(i, f.name, "█")
|
||||
elif val and isinstance(val, list): # jsonfield
|
||||
setattr(i, f.name, [])
|
||||
elif val and isinstance(val, dict): # jsonfield
|
||||
setattr(i, f.name, {"_shredded": True})
|
||||
i.save()
|
||||
|
||||
InvoiceLine.objects.filter(
|
||||
attendee_name__isnull=False,
|
||||
invoice__shredded=True
|
||||
).update(attendee_name="█")
|
||||
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0310_question_valid_string_length_min"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(
|
||||
fix_unshredded_invoices,
|
||||
migrations.RunPython.noop,
|
||||
),
|
||||
]
|
||||
@@ -166,6 +166,7 @@ class Device(LoggedModel):
|
||||
)
|
||||
security_profile = models.CharField(
|
||||
max_length=190,
|
||||
verbose_name=_('Security profile'),
|
||||
default='full',
|
||||
null=True,
|
||||
blank=False
|
||||
|
||||
+35
-17
@@ -801,6 +801,18 @@ def generate_compressed_addon_list(op, order, event, only_checked_in=False):
|
||||
return addonlist
|
||||
|
||||
|
||||
def get_sizebox(page: pypdf.PageObject):
|
||||
mediabox = page.mediabox
|
||||
cropbox = page.cropbox
|
||||
|
||||
return pypdf.generic.RectangleObject((
|
||||
max(mediabox[0], cropbox[0]),
|
||||
max(mediabox[1], cropbox[1]),
|
||||
min(mediabox[2], cropbox[2]),
|
||||
min(mediabox[3], cropbox[3]),
|
||||
))
|
||||
|
||||
|
||||
class Renderer:
|
||||
|
||||
def __init__(self, event, layout, background_file):
|
||||
@@ -1153,11 +1165,10 @@ class Renderer:
|
||||
elif o['type'] == "poweredby":
|
||||
self._draw_poweredby(canvas, op, o)
|
||||
if self.bg_pdf:
|
||||
page_size = (
|
||||
self.bg_pdf.pages[0].mediabox[2] - self.bg_pdf.pages[0].mediabox[0],
|
||||
self.bg_pdf.pages[0].mediabox[3] - self.bg_pdf.pages[0].mediabox[1]
|
||||
)
|
||||
if self.bg_pdf.pages[0].get('/Rotate') in (90, 270):
|
||||
first_page = self.bg_pdf.pages[0]
|
||||
sizebox = get_sizebox(first_page)
|
||||
page_size = (sizebox.width, sizebox.height)
|
||||
if first_page.rotation in (90, 270):
|
||||
# swap dimensions due to pdf being rotated
|
||||
page_size = page_size[::-1]
|
||||
canvas.setPageSize(page_size)
|
||||
@@ -1297,26 +1308,33 @@ def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
|
||||
bg_pdf.write(bg_filename)
|
||||
subprocess.run(pdftk_cmd, check=True, stdout=out_file)
|
||||
else:
|
||||
output = PdfWriter()
|
||||
for i, page in enumerate(fg_pdf.pages):
|
||||
bg_page = bg_pdf.pages[i]
|
||||
_merge_with_correct_page_media_box(fg_pdf, page, bg_page)
|
||||
_merge_with_correct_page_media_box(output, page, bg_page)
|
||||
|
||||
# pdf_header is a string like "%pdf-X.X"
|
||||
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:]):
|
||||
fg_pdf.pdf_header = bg_pdf.pdf_header
|
||||
|
||||
fg_pdf.write(out_file)
|
||||
output.pdf_header = (
|
||||
bg_pdf.pdf_header
|
||||
if float(bg_pdf.pdf_header[5:]) > float(fg_pdf.pdf_header[5:])
|
||||
else fg_pdf.pdf_header
|
||||
)
|
||||
output.write(out_file)
|
||||
|
||||
|
||||
def _merge_with_correct_page_media_box(output: pypdf.PdfWriter, fg_page: pypdf.PageObject, bg_page: pypdf.PageObject):
|
||||
if bg_page.rotation != 0:
|
||||
bg_page.transfer_rotation_to_content()
|
||||
media_box = bg_page.mediabox
|
||||
"""
|
||||
Adds fg_page to output, merging bg_page behind it.
|
||||
|
||||
If bg_page has a non-zero mergebox/cropbox or is rotated via /Rotate, a transformation is applied to fix this."""
|
||||
trsf = pypdf.Transformation()
|
||||
if media_box.bottom != 0:
|
||||
trsf = trsf.translate(0, -media_box.bottom)
|
||||
if media_box.left != 0:
|
||||
trsf = trsf.translate(-media_box.left, 0)
|
||||
if bg_page.rotation != 0:
|
||||
trsf = trsf.rotate(-bg_page.rotation)
|
||||
|
||||
mb = get_sizebox(bg_page)
|
||||
pt1 = trsf.apply_on(mb.lower_left)
|
||||
pt2 = trsf.apply_on(mb.upper_right)
|
||||
trsf = trsf.translate(-min(pt1[0], pt2[0]), -min(pt1[1], pt2[1]))
|
||||
|
||||
fg_page = output.add_page(fg_page)
|
||||
fg_page.merge_transformed_page(bg_page, trsf, over=False, expand=False)
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
import logging
|
||||
import os
|
||||
from decimal import Decimal
|
||||
|
||||
from datetime import timedelta
|
||||
from django.core.files.base import ContentFile
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext as _
|
||||
@@ -31,18 +31,19 @@ from django_scopes import scopes_disabled
|
||||
from pretix.base.i18n import language
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
|
||||
OrderPosition,
|
||||
OrderPosition, CachedFile
|
||||
)
|
||||
from pretix.base.services.tasks import EventTask, ProfiledTask
|
||||
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__)
|
||||
|
||||
|
||||
def generate_orderposition(order_position: int, provider: str):
|
||||
def generate_orderposition(order_position: int, provider: str, cf: CachedFile | None = None):
|
||||
order_position = OrderPosition.objects.select_related('order', 'order__event').get(id=order_position)
|
||||
|
||||
with language(order_position.order.locale, order_position.order.event.settings.region):
|
||||
@@ -52,15 +53,22 @@ def generate_orderposition(order_position: int, provider: str):
|
||||
if prov.identifier == provider:
|
||||
filename, ttype, data = prov.generate(order_position)
|
||||
path, ext = os.path.splitext(filename)
|
||||
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
|
||||
extension=ext, type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
if cf:
|
||||
cf.type = ttype
|
||||
cf.filename = filename
|
||||
cf.file.save(name=filename, content=ContentFile(data))
|
||||
return cf.pk
|
||||
|
||||
def generate_order(order: int, provider: str):
|
||||
if prov.is_cacheable:
|
||||
for ct in CachedTicket.objects.filter(order_position=order_position, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedTicket.objects.create(order_position=order_position, provider=provider,
|
||||
extension=ext, type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
def generate_order(order: int, provider: str, cf: CachedFile | None = None):
|
||||
order = Order.objects.select_related('event').get(id=order)
|
||||
|
||||
with language(order.locale, order.event.settings.region):
|
||||
@@ -70,56 +78,74 @@ def generate_order(order: int, provider: str):
|
||||
if prov.identifier == provider:
|
||||
filename, ttype, data = prov.generate_order(order)
|
||||
path, ext = os.path.splitext(filename)
|
||||
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
|
||||
type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
if cf:
|
||||
cf.type = ttype
|
||||
cf.filename = filename
|
||||
cf.file.save(name=filename, content=ContentFile(data))
|
||||
return cf.pk
|
||||
|
||||
if prov.is_cacheable:
|
||||
for ct in CachedCombinedTicket.objects.filter(order=order, provider=provider):
|
||||
ct.delete()
|
||||
ct = CachedCombinedTicket.objects.create(order=order, provider=provider, extension=ext,
|
||||
type=ttype, file=None)
|
||||
ct.file.save(filename, ContentFile(data))
|
||||
return ct.pk
|
||||
|
||||
@app.task(base=ProfiledTask)
|
||||
def generate(model: str, pk: int, provider: str):
|
||||
def generate(model: str, pk: int, provider: str, cachedfile_pk: int | None = None):
|
||||
with scopes_disabled():
|
||||
if cachedfile_pk:
|
||||
cf = CachedFile.objects.get(pk=cachedfile_pk)
|
||||
else:
|
||||
cf = None
|
||||
if model == 'order':
|
||||
return generate_order(pk, provider)
|
||||
return generate_order(pk, provider, cf)
|
||||
elif model == 'orderposition':
|
||||
return generate_orderposition(pk, provider)
|
||||
return generate_orderposition(pk, provider, cf)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
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)
|
||||
|
||||
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):
|
||||
@@ -143,7 +169,7 @@ def get_tickets_for_order(order, base_position=None):
|
||||
]
|
||||
|
||||
for p in providers:
|
||||
if not p.is_enabled:
|
||||
if not p.is_enabled or not p.is_cacheable:
|
||||
continue
|
||||
|
||||
if p.multi_download_enabled and not base_position:
|
||||
|
||||
+22
-10
@@ -50,8 +50,8 @@ from pretix.api.serializers.order import (
|
||||
from pretix.api.serializers.waitinglist import WaitingListSerializer
|
||||
from pretix.base.i18n import LazyLocaleException
|
||||
from pretix.base.models import (
|
||||
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
|
||||
OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
|
||||
CachedCombinedTicket, CachedTicket, Event, Invoice, InvoiceAddress,
|
||||
OrderPayment, OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
|
||||
)
|
||||
from pretix.base.services.invoices import invoice_pdf_task
|
||||
from pretix.base.signals import register_data_shredders
|
||||
@@ -598,18 +598,30 @@ class InvoiceShredder(BaseDataShredder):
|
||||
def shred_data(self, progress_callback=None):
|
||||
qs_i = self.event.invoices.filter(shredded=False)
|
||||
total = qs_i.count()
|
||||
ignore_fields = (
|
||||
'prefix', 'invoice_no', 'full_invoice_no', 'invoice_from', 'invoice_from_name', 'invoice_from_zipcode',
|
||||
'invoice_from_city', 'invoice_from_state', 'invoice_from_country', 'invoice_from_tax_id',
|
||||
'invoice_from_vat_id', 'locale', 'payment_provider_stamp', 'footer_text', 'foreign_currency_display',
|
||||
'foreign_currency_source', 'transmission_type', 'transmission_provider', 'transmission_status',
|
||||
)
|
||||
|
||||
for i in _progress_helper(qs_i, progress_callback, 0, total):
|
||||
if i.file:
|
||||
i.file.delete()
|
||||
i.shredded = True
|
||||
i.introductory_text = "█"
|
||||
i.additional_text = "█"
|
||||
i.invoice_to = "█"
|
||||
i.payment_provider_text = "█"
|
||||
i.transmission_info = {"_shredded": True}
|
||||
i.save()
|
||||
i.lines.update(description="█")
|
||||
i.shredded = True
|
||||
|
||||
for f in Invoice._meta.fields:
|
||||
if f.name in ignore_fields:
|
||||
continue
|
||||
val = getattr(i, f.name, None)
|
||||
if val and isinstance(val, str):
|
||||
setattr(i, f.name, "█")
|
||||
elif val and isinstance(val, list): # jsonfield
|
||||
setattr(i, f.name, [])
|
||||
elif val and isinstance(val, dict): # jsonfield
|
||||
setattr(i, f.name, {"_shredded": True})
|
||||
i.save()
|
||||
i.lines.update(description="█", attendee_name="█")
|
||||
|
||||
|
||||
class CachedTicketShredder(BaseDataShredder):
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from decimal import ROUND_HALF_UP, Decimal
|
||||
from typing import Optional
|
||||
|
||||
from babel import Locale, UnknownLocaleError
|
||||
from babel.numbers import format_currency
|
||||
@@ -35,32 +36,32 @@ register = template.Library()
|
||||
|
||||
|
||||
@register.filter("money")
|
||||
def money_filter(value: Decimal, arg='', hide_currency=False):
|
||||
if isinstance(value, (float, int)):
|
||||
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False) -> str:
|
||||
if isinstance(value, (float, int, str)):
|
||||
if value == '':
|
||||
return value # pyright: ignore[reportReturnType]
|
||||
value = Decimal(value)
|
||||
if value is None:
|
||||
value = Decimal('0.00')
|
||||
if not isinstance(value, Decimal):
|
||||
if value == '':
|
||||
return value
|
||||
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
|
||||
if not arg:
|
||||
raise ValueError("No currency passed.")
|
||||
arg = arg.upper()
|
||||
|
||||
places = settings.CURRENCY_PLACES.get(arg, 2)
|
||||
rounded = value.quantize(Decimal('1') / 10 ** places, ROUND_HALF_UP)
|
||||
if places < 2 and rounded != value:
|
||||
# We display decimal places even if we shouldn't for this currency if rounding
|
||||
# would make the numbers incorrect. If this branch executes, it's likely a bug in
|
||||
# pretix, but we won't show wrong numbers!
|
||||
if hide_currency:
|
||||
return floatformat(value, "2g")
|
||||
else:
|
||||
return '{} {}'.format(arg, floatformat(value, "2g"))
|
||||
if value.normalize().as_tuple().exponent < -9:
|
||||
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
|
||||
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
|
||||
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
|
||||
# land to this filter (even though it should not happen).
|
||||
value = value.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
|
||||
|
||||
currency_places = settings.CURRENCY_PLACES.get(arg, 2)
|
||||
required_places = -value.normalize().as_tuple().exponent
|
||||
render_places = max(currency_places, required_places)
|
||||
|
||||
if hide_currency:
|
||||
return floatformat(value, f"{places}g")
|
||||
return floatformat(value, f"{render_places}g")
|
||||
|
||||
try:
|
||||
locale = Locale(get_babel_locale())
|
||||
@@ -68,14 +69,29 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
|
||||
locale = "en"
|
||||
|
||||
try:
|
||||
return format_currency(value, arg, locale=locale)
|
||||
return format_currency(
|
||||
value,
|
||||
arg,
|
||||
locale=locale,
|
||||
# We only allow Babel to restrict the digits to the digits defined by the currency if this does not remove any
|
||||
# precision in case we have sub-currency precision (which we shouldn't have in most places, but it's still
|
||||
# better than showing wrong data). Note: Weird precision effects can occur after in-database arithmetic
|
||||
# on SQLite, since SQLite does not have fixed-decimal computation.
|
||||
currency_digits=currency_places >= required_places,
|
||||
decimal_quantization=currency_places >= required_places,
|
||||
)
|
||||
except:
|
||||
return '{} {}'.format(arg, floatformat(value, f"{places}g"))
|
||||
return '{} {}'.format(arg, floatformat(value, f"{render_places}g"))
|
||||
|
||||
|
||||
@register.filter("money_without_currency")
|
||||
def money_filter_without_currency(value: Optional[Decimal | float | int | str], arg=''):
|
||||
return money_filter(value, arg, hide_currency=True)
|
||||
|
||||
|
||||
@register.filter("money_numberfield")
|
||||
def money_numberfield_filter(value: Decimal, arg=''):
|
||||
if isinstance(value, (float, int)):
|
||||
def money_numberfield_filter(value: Optional[Decimal | float | int | str], arg=''):
|
||||
if isinstance(value, (float, int, str)):
|
||||
value = Decimal(value)
|
||||
if not isinstance(value, Decimal):
|
||||
raise TypeError("Invalid data type passed to money filter: %r" % type(value))
|
||||
@@ -87,15 +103,28 @@ def money_numberfield_filter(value: Decimal, arg=''):
|
||||
|
||||
|
||||
@register.filter(is_safe=True)
|
||||
def tax_rate_format(number):
|
||||
def tax_rate_format(number: Optional[Decimal | float | int | str]):
|
||||
"""
|
||||
Display a Decimal to its significant decimal places, used for tax rates.
|
||||
"""
|
||||
assert isinstance(number, Decimal)
|
||||
if isinstance(number, (float, int, str)):
|
||||
if number == '':
|
||||
return number
|
||||
number = Decimal(number)
|
||||
if number is None:
|
||||
number = Decimal('0.00')
|
||||
if not isinstance(number, Decimal):
|
||||
raise TypeError("Invalid data type passed to tax rate format filter: %r" % type(number))
|
||||
if number.normalize().as_tuple().exponent < -9:
|
||||
# Heuristic: It's unlikely we'll ever see values of less than 0.000000001 in any currency. Therefore, if we
|
||||
# do see them, we very likely deal with a floating point error. This happens mostly in dev mode when computations
|
||||
# are made in SQLite, which uses REAL precision, but it can also happen when we naively pass a float from Python
|
||||
# land to this filter (even though it should not happen).
|
||||
number = number.quantize(Decimal('1e-9'), ROUND_HALF_UP).normalize()
|
||||
return mark_safe(
|
||||
formats.number_format(
|
||||
number.normalize(),
|
||||
-number.as_tuple().exponent,
|
||||
number,
|
||||
-number.normalize().as_tuple().exponent,
|
||||
use_l10n=True,
|
||||
force_grouping=False,
|
||||
)
|
||||
|
||||
@@ -114,7 +114,7 @@ class BaseTicketOutput:
|
||||
If you override this method, make sure that positions that are addons (i.e. ``addon_to``
|
||||
is set) are only outputted if the event setting ``ticket_download_addons`` is active.
|
||||
Do the same for positions that are non-admission without ``ticket_download_nonadm`` active.
|
||||
If you want, you can just iterate over ``order.positions_with_tickets`` which applies the
|
||||
If you want, you can just iterate over ``self.get_tickets_to_print`` which applies the
|
||||
appropriate filters for you.
|
||||
"""
|
||||
with tempfile.TemporaryDirectory() as d:
|
||||
@@ -192,6 +192,17 @@ class BaseTicketOutput:
|
||||
"""
|
||||
pass
|
||||
|
||||
@property
|
||||
def is_meta(self) -> bool:
|
||||
"""
|
||||
Returns whether or whether not this output is a "meta" output that only works as a settings holder
|
||||
and should never be used directly. This is a trick to implement outputs with multiple formats but
|
||||
unified settings.
|
||||
|
||||
.. note:: You should set is_enabled to False for meta outputs.
|
||||
"""
|
||||
return False
|
||||
|
||||
@property
|
||||
def download_button_text(self) -> str:
|
||||
"""
|
||||
@@ -220,6 +231,19 @@ class BaseTicketOutput:
|
||||
"""
|
||||
return 'fa-download'
|
||||
|
||||
@property
|
||||
def confirmation_text(self) -> str | None:
|
||||
"""
|
||||
A text the user has to confirm before a pass is generated.
|
||||
|
||||
Returning None means no confirmation screen is shown.
|
||||
"""
|
||||
return None
|
||||
|
||||
@property
|
||||
def is_cacheable(self) -> bool:
|
||||
return True
|
||||
|
||||
@property
|
||||
def preview_allowed(self) -> bool:
|
||||
"""
|
||||
|
||||
@@ -400,10 +400,10 @@ class EventMetaValueForm(forms.ModelForm):
|
||||
if self.disabled:
|
||||
self.fields['value'].widget.attrs['readonly'] = 'readonly'
|
||||
|
||||
def clean_slug(self):
|
||||
def clean_value(self):
|
||||
if self.disabled:
|
||||
return self.instance.value if self.instance else None
|
||||
return self.cleaned_data['slug']
|
||||
return self.cleaned_data['value']
|
||||
|
||||
class Meta:
|
||||
model = EventMetaValue
|
||||
@@ -1004,7 +1004,7 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
|
||||
class ProviderForm(SettingsForm):
|
||||
"""
|
||||
This is a SettingsForm, but if fields are set to required=True, validation
|
||||
errors are only raised if the payment method is enabled.
|
||||
errors are only raised if the provider is enabled.
|
||||
"""
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
||||
@@ -435,10 +435,10 @@ class SubEventMetaValueForm(forms.ModelForm):
|
||||
if self.disabled:
|
||||
self.fields['value'].widget.attrs['readonly'] = 'readonly'
|
||||
|
||||
def clean_slug(self):
|
||||
def clean_value(self):
|
||||
if self.disabled:
|
||||
return self.instance.value if self.instance else None
|
||||
return self.cleaned_data['slug']
|
||||
return self.cleaned_data['value']
|
||||
|
||||
class Meta:
|
||||
model = SubEventMetaValue
|
||||
|
||||
@@ -10,6 +10,7 @@
|
||||
{% load l10n %}
|
||||
{% load phone_format %}
|
||||
{% load getitem %}
|
||||
{% load dialog %}
|
||||
{% block title %}
|
||||
{% blocktrans trimmed with code=order.code %}
|
||||
Order details: {{ code }}
|
||||
@@ -563,15 +564,41 @@
|
||||
<div class="position-buttons">
|
||||
{% if line.generate_ticket %}
|
||||
{% for b in download_buttons %}
|
||||
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-xs btn-default">
|
||||
{% if b.confirmation_text %}
|
||||
<button type="button" class="btn btn-xs btn-default" data-toggle="modal" data-target="#download-confirm-{{b.identifier}}">
|
||||
<span class="fa {{ b.icon }}"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
<dialog class="modal modal-card" id="download-confirm-{{b.identifier}}" tabindex="-1" role="dialog" aria-labelledby="download-confirm-{{b.identifier}}-label">
|
||||
<div class="modal-card-inner" role="document">
|
||||
<div class="modal-card-icon"><span class="fa fa-shield" aria-hidden="true"></span></div>
|
||||
<div class="modal-card-content">
|
||||
<h4 class="modal-card-title h3" id="download-confirm-{{b.identifier}}-label">{% trans "Confirmation required" %}</h4>
|
||||
<p>{{ b.confirmation_text }}</p>
|
||||
<div class="modal-card-confirm modal-card-confirm-spread">
|
||||
<button type="button" class="btn btn-default btn-lg" data-dismiss="modal">{% trans "Cancel" %}</button>
|
||||
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-primary btn-lg">
|
||||
{% trans "Confirm" %}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</dialog>
|
||||
{% else %}
|
||||
<form action="{% url "control:event.order.download.ticket" code=order.code event=request.event.slug organizer=request.event.organizer.slug position=line.pk output=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-xs btn-default">
|
||||
<span class="fa {{ b.icon }}"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
<button type="submit" data-toggle="qrcode" data-qrcode="{{ line.secret }}"
|
||||
|
||||
@@ -35,6 +35,7 @@
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
import time
|
||||
from urllib.parse import quote, urljoin, urlparse
|
||||
|
||||
@@ -50,11 +51,12 @@ from django.shortcuts import redirect, render
|
||||
from django.urls import reverse
|
||||
from django.utils.functional import cached_property
|
||||
from django.utils.http import url_has_allowed_host_and_scheme
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from django.utils.timezone import now
|
||||
from django.utils.translation import gettext_lazy as _, ngettext
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
from django.views.decorators.http import require_http_methods
|
||||
from django.views.generic import TemplateView
|
||||
from django_otp import match_token
|
||||
from django_otp import devices_for_user
|
||||
from django_otp.plugins.otp_static.models import StaticDevice
|
||||
from webauthn.helpers import generate_challenge
|
||||
|
||||
@@ -463,6 +465,7 @@ class Login2FAView(TemplateView):
|
||||
token = request.POST.get('token', '').strip().replace(' ', '')
|
||||
|
||||
valid = False
|
||||
retry_after = None
|
||||
if 'webauthn_challenge' in self.request.session and token.startswith('{'):
|
||||
challenge = self.request.session['webauthn_challenge']
|
||||
|
||||
@@ -518,12 +521,28 @@ class Login2FAView(TemplateView):
|
||||
valid = True
|
||||
break
|
||||
else:
|
||||
valid = match_token(self.user, token)
|
||||
if isinstance(valid, StaticDevice):
|
||||
with transaction.atomic():
|
||||
for device in devices_for_user(self.user, for_verify=True):
|
||||
if isinstance(device, StaticDevice) and len(token) < 12:
|
||||
# If we enter a wrong TOTP token (which is 6 characters), do not even try if it is a valid
|
||||
# emergency token, which will only "lock up" the StaticDevice due to the throttling plugin
|
||||
# and just locks people out without security gain.
|
||||
continue
|
||||
if device.verify_token(token):
|
||||
valid = True
|
||||
break
|
||||
elif hasattr(device, 'verify_is_allowed'):
|
||||
verify_allowed, reason_dict = device.verify_is_allowed()
|
||||
if not verify_allowed:
|
||||
if not retry_after or reason_dict['locked_until'] > retry_after:
|
||||
retry_after = reason_dict['locked_until']
|
||||
else:
|
||||
device = None
|
||||
|
||||
if isinstance(device, StaticDevice):
|
||||
self.user.send_security_notice([
|
||||
_("A recovery code for two-factor authentification was used to log in.")
|
||||
])
|
||||
|
||||
if valid:
|
||||
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
|
||||
pretix_successful_logins.inc(1)
|
||||
@@ -536,7 +555,23 @@ class Login2FAView(TemplateView):
|
||||
return redirect('control:index')
|
||||
else:
|
||||
pretix_failed_logins.inc(1, reason="2fa")
|
||||
messages.error(request, _('Invalid code, please try again.'))
|
||||
msg = _('Invalid code, please try again.')
|
||||
if retry_after:
|
||||
seconds = (retry_after - now()).total_seconds()
|
||||
minutes = seconds / 60
|
||||
if minutes >= 1:
|
||||
msg = ngettext(
|
||||
'Invalid code. Please try again after waiting {value} minute.',
|
||||
'Invalid code. Please try again after waiting {value} minutes.',
|
||||
minutes,
|
||||
).format(value=math.ceil(minutes))
|
||||
elif seconds >= 1:
|
||||
msg = ngettext(
|
||||
'Invalid code. Please try again after waiting {value} second.',
|
||||
'Invalid code. Please try again after waiting {value} seconds.',
|
||||
seconds,
|
||||
).format(value=math.ceil(seconds))
|
||||
messages.error(request, msg)
|
||||
return redirect('control:auth.login.2fa')
|
||||
|
||||
def get_context_data(self, **kwargs):
|
||||
|
||||
@@ -982,7 +982,7 @@ class TicketSettingsPreview(EventPermissionRequiredMixin, View):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if provider.identifier == self.kwargs.get('output'):
|
||||
if provider.identifier == self.kwargs.get('output') and not provider.is_meta:
|
||||
return provider
|
||||
|
||||
def get(self, request, *args, **kwargs):
|
||||
@@ -1085,6 +1085,11 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
provider_settings_fields = provider.settings_form_fields
|
||||
provider_settings_content = provider.settings_content_render(self.request)
|
||||
if not provider_settings_fields and not provider_settings_content:
|
||||
continue
|
||||
|
||||
provider.form = ProviderForm(
|
||||
obj=self.request.event,
|
||||
settingspref='ticketoutput_%s_' % provider.identifier,
|
||||
@@ -1094,17 +1099,17 @@ class TicketSettings(EventSettingsViewMixin, EventPermissionRequiredMixin, FormV
|
||||
provider.form.fields = OrderedDict(
|
||||
[
|
||||
('ticketoutput_%s_%s' % (provider.identifier, k), v)
|
||||
for k, v in provider.settings_form_fields.items()
|
||||
for k, v in provider_settings_fields.items()
|
||||
]
|
||||
)
|
||||
provider.settings_content = provider.settings_content_render(self.request)
|
||||
provider.settings_content = provider_settings_content
|
||||
provider.form.prepare_fields()
|
||||
|
||||
provider.evaluated_preview_allowed = True
|
||||
if not provider.preview_allowed:
|
||||
provider.evaluated_preview_allowed = False
|
||||
else:
|
||||
for k, v in provider.settings_form_fields.items():
|
||||
for k, v in provider_settings_fields.items():
|
||||
if v.required and not self.request.event.settings.get('ticketoutput_%s_%s' % (provider.identifier, k)):
|
||||
provider.evaluated_preview_allowed = False
|
||||
break
|
||||
|
||||
@@ -582,12 +582,15 @@ class OrderDetail(OrderView):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
for receiver, response in responses:
|
||||
provider = response(self.request.event)
|
||||
if provider.is_meta:
|
||||
continue
|
||||
buttons.append({
|
||||
'text': provider.download_button_text or 'Ticket',
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
'identifier': provider.identifier,
|
||||
'multi': provider.multi_download_enabled,
|
||||
'javascript_required': provider.javascript_required
|
||||
'javascript_required': provider.javascript_required,
|
||||
'confirmation_text': provider.confirmation_text
|
||||
})
|
||||
return buttons
|
||||
|
||||
|
||||
@@ -778,9 +778,9 @@ class OrganizerPluginEvents(OrganizerDetailViewMixin, OrganizerPermissionRequire
|
||||
|
||||
def get_form_kwargs(self):
|
||||
kwargs = super().get_form_kwargs()
|
||||
kwargs["events"] = self.request.user.get_events_with_permission(
|
||||
"event.settings.general:write", request=self.request
|
||||
).filter(organizer=self.request.organizer)
|
||||
# Assumption: Who has access to modify organizer settings may see all events and disable/enable plugins
|
||||
# for them. Otherwise, inconsistent situations occur.
|
||||
kwargs["events"] = self.request.organizer.events.all()
|
||||
kwargs["initial"] = {
|
||||
"events": self.request.organizer.events.filter(plugins__regex='(^|,)' + self.plugin.module + '(,|$)')
|
||||
}
|
||||
|
||||
@@ -1276,6 +1276,11 @@ class SubEventBulkEdit(SubEventQueryMixin, EventPermissionRequiredMixin, FormVie
|
||||
self._default_meta = self.request.event.meta_data
|
||||
|
||||
for p in self.request.organizer.meta_properties.all():
|
||||
if p.protected and not self.request.user.has_organizer_permission(
|
||||
self.request.organizer, 'organizer.settings.general:write', request=self.request
|
||||
):
|
||||
continue
|
||||
|
||||
inst = SubEventMetaValue(property=p)
|
||||
if len(matches[p.id]) == 1 and matches[p.id][0]['c'] == total:
|
||||
inst.value = matches[p.id][0]['value']
|
||||
|
||||
@@ -21,11 +21,13 @@
|
||||
#
|
||||
import copy
|
||||
from decimal import Decimal
|
||||
import typing
|
||||
|
||||
from django.core.files import File
|
||||
from django.db import models
|
||||
from django.db.models.fields import DecimalField
|
||||
|
||||
T = typing.TypeVar('T', bound=models.Model)
|
||||
|
||||
class Thumbnail(models.Model):
|
||||
source = models.CharField(max_length=255)
|
||||
@@ -47,6 +49,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):
|
||||
|
||||
@@ -8,8 +8,8 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-06-25 14:00+0000\n"
|
||||
"Last-Translator: Kim Lozano <joaquim.lozano@upc.edu>\n"
|
||||
"PO-Revision-Date: 2026-09-20 14:00+0000\n"
|
||||
"Last-Translator: Rita Gimenez <barcelonamusictech@gmail.com>\n"
|
||||
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"ca/>\n"
|
||||
"Language: ca\n"
|
||||
@@ -17,7 +17,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.6.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -37,11 +37,11 @@ msgstr "Àrab"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Basque"
|
||||
msgstr ""
|
||||
msgstr "Basc"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Catalan"
|
||||
msgstr ""
|
||||
msgstr "Català"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Chinese (simplified)"
|
||||
@@ -49,11 +49,11 @@ msgstr "Xinès (simplificat)"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Chinese (traditional)"
|
||||
msgstr ""
|
||||
msgstr "Xinès (tradicional)"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Czech"
|
||||
msgstr ""
|
||||
msgstr "Txec"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Croatian"
|
||||
@@ -81,7 +81,7 @@ msgstr "Finlandès"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Galician"
|
||||
msgstr ""
|
||||
msgstr "Gallec"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Greek"
|
||||
@@ -89,15 +89,15 @@ msgstr "Grec"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Hebrew"
|
||||
msgstr ""
|
||||
msgstr "Hebreu"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Hungarian"
|
||||
msgstr ""
|
||||
msgstr "Hongarès"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Indonesian"
|
||||
msgstr ""
|
||||
msgstr "Indonesi"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Italian"
|
||||
@@ -105,7 +105,7 @@ msgstr "Italià"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Japanese"
|
||||
msgstr ""
|
||||
msgstr "Japonès"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Latvian"
|
||||
@@ -113,7 +113,7 @@ msgstr "Letó"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Norwegian Bokmål"
|
||||
msgstr ""
|
||||
msgstr "Noruec"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Polish"
|
||||
@@ -129,7 +129,7 @@ msgstr "Portuguès (Brasil)"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Romanian"
|
||||
msgstr ""
|
||||
msgstr "Romanès"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Russian"
|
||||
@@ -137,11 +137,11 @@ msgstr "Rus"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Slovak"
|
||||
msgstr ""
|
||||
msgstr "Eslovac"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Swedish"
|
||||
msgstr ""
|
||||
msgstr "Suec"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Spanish"
|
||||
@@ -149,11 +149,11 @@ msgstr "Espanyol"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Spanish (Latin America)"
|
||||
msgstr ""
|
||||
msgstr "Espanyol (Llatinoamèrica)"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Thai"
|
||||
msgstr ""
|
||||
msgstr "Tailandès"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Turkish"
|
||||
@@ -161,13 +161,15 @@ msgstr "Turc"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "Ukrainian"
|
||||
msgstr ""
|
||||
msgstr "Ucraïnès"
|
||||
|
||||
#: pretix/api/auth/devicesecurity.py
|
||||
msgid ""
|
||||
"Full device access (reading and changing orders and gift cards, reading of "
|
||||
"products and settings)"
|
||||
msgstr ""
|
||||
"Accés total al dispositiu (lectura i modificació de comandes i targetes "
|
||||
"regal, lectura de productes i configuració)"
|
||||
|
||||
#: pretix/api/auth/devicesecurity.py
|
||||
msgid "pretixSCAN"
|
||||
@@ -309,7 +311,7 @@ msgstr "La cistella és buida."
|
||||
|
||||
#: pretix/api/serializers/item.py
|
||||
msgid "The program end must not be empty."
|
||||
msgstr ""
|
||||
msgstr "El final del programa no ha de ser buit."
|
||||
|
||||
#: pretix/api/serializers/item.py pretix/base/models/items.py
|
||||
#, fuzzy
|
||||
@@ -339,7 +341,7 @@ msgstr ""
|
||||
|
||||
#: pretix/api/serializers/item.py
|
||||
msgid "Only admission products can currently be personalized."
|
||||
msgstr ""
|
||||
msgstr "Actualment, només es poden personalitzar els productes d'accés."
|
||||
|
||||
#: pretix/api/serializers/item.py
|
||||
msgid ""
|
||||
@@ -434,7 +436,7 @@ msgstr ""
|
||||
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
|
||||
#, python-format
|
||||
msgid "You've been invited to join %(organizer)s"
|
||||
msgstr ""
|
||||
msgstr "Has estat convidat/da a unir-te a %(organizer)s"
|
||||
|
||||
#: pretix/api/serializers/organizer.py pretix/control/views/organizer.py
|
||||
msgid "This user already has been invited for this team."
|
||||
@@ -456,7 +458,7 @@ msgstr ""
|
||||
|
||||
#: pretix/api/views/checkin.py
|
||||
msgid "Medium connected to other event"
|
||||
msgstr ""
|
||||
msgstr "Mitjà connectat a un altre esdeveniment"
|
||||
|
||||
#: pretix/api/views/checkin.py
|
||||
#, fuzzy
|
||||
@@ -642,6 +644,8 @@ msgid ""
|
||||
"This includes product added or deleted and changes to nested objects like "
|
||||
"variations or bundles."
|
||||
msgstr ""
|
||||
"Això inclou productes afegits o eliminats i canvis en objectes imbricats, "
|
||||
"com ara variacions o lots."
|
||||
|
||||
#: pretix/api/webhooks.py
|
||||
#, fuzzy
|
||||
@@ -654,6 +658,9 @@ msgid ""
|
||||
"This includes related events like creation, deletion, opening or closing of "
|
||||
"quotas. No webhook is sent for changes to the resulting availability."
|
||||
msgstr ""
|
||||
"Això inclou esdeveniments relacionats, com ara la creació, l'eliminació, "
|
||||
"l'obertura o el tancament de quotes. No s'envia cap webhook per als canvis "
|
||||
"en la disponibilitat resultant."
|
||||
|
||||
#: pretix/api/webhooks.py
|
||||
#, fuzzy
|
||||
|
||||
@@ -5,7 +5,7 @@ msgstr ""
|
||||
"Project-Id-Version: 1\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-08-25 00:00+0000\n"
|
||||
"PO-Revision-Date: 2026-09-14 23:00+0000\n"
|
||||
"Last-Translator: Raphael Michel <michel@rami.io>\n"
|
||||
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix/"
|
||||
"de/>\n"
|
||||
@@ -14,7 +14,7 @@ msgstr ""
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=2; plural=n != 1;\n"
|
||||
"X-Generator: Weblate 2026.8.1\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
"X-Poedit-Bookmarks: -1,-1,904,-1,-1,-1,-1,-1,-1,-1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
@@ -11708,7 +11708,7 @@ msgid ""
|
||||
msgstr ""
|
||||
"Hallo,\n"
|
||||
"\n"
|
||||
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihrer Bestellung "
|
||||
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihrer Bestellung "
|
||||
"für\n"
|
||||
"{event} angefordert haben.\n"
|
||||
"\n"
|
||||
@@ -11738,8 +11738,8 @@ msgid ""
|
||||
msgstr ""
|
||||
"Hallo,\n"
|
||||
"\n"
|
||||
"Sie erhalten diese Nachricht weil Sie einen neuen Link zu Ihren Bestellungen "
|
||||
"für\n"
|
||||
"Sie erhalten diese Nachricht, weil Sie einen neuen Link zu Ihren "
|
||||
"Bestellungen für\n"
|
||||
"{event} angefordert haben. Sie finden Ihre Bestellungen unter folgenden "
|
||||
"Links:\n"
|
||||
"\n"
|
||||
|
||||
@@ -8,16 +8,16 @@ msgstr ""
|
||||
"Project-Id-Version: PACKAGE VERSION\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2026-08-24 15:35+0000\n"
|
||||
"PO-Revision-Date: 2026-05-20 10:58+0000\n"
|
||||
"Last-Translator: Phumraphee Sae-tang <phumraphee@gmail.com>\n"
|
||||
"Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix/th/"
|
||||
">\n"
|
||||
"PO-Revision-Date: 2026-09-19 13:00+0000\n"
|
||||
"Last-Translator: Nate Horst <nate@agcthailand.org>\n"
|
||||
"Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix/th/>"
|
||||
"\n"
|
||||
"Language: th\n"
|
||||
"MIME-Version: 1.0\n"
|
||||
"Content-Type: text/plain; charset=UTF-8\n"
|
||||
"Content-Transfer-Encoding: 8bit\n"
|
||||
"Plural-Forms: nplurals=1; plural=0;\n"
|
||||
"X-Generator: Weblate 2026.5\n"
|
||||
"X-Generator: Weblate 2026.9.1\n"
|
||||
|
||||
#: pretix/_base_settings.py
|
||||
msgid "English"
|
||||
@@ -17264,7 +17264,7 @@ msgstr ""
|
||||
#: pretix/control/navigation.py
|
||||
#: pretix/control/templates/pretixcontrol/dashboard.html
|
||||
msgid "Dashboard"
|
||||
msgstr ""
|
||||
msgstr "แผงควบคุม"
|
||||
|
||||
#: pretix/control/navigation.py
|
||||
#: pretix/control/templates/pretixcontrol/checkin/list_edit.html
|
||||
@@ -17278,7 +17278,7 @@ msgstr ""
|
||||
#: pretix/control/templates/pretixcontrol/organizers/mail.html
|
||||
#: pretix/control/templates/pretixcontrol/organizers/property_edit.html
|
||||
msgid "General"
|
||||
msgstr ""
|
||||
msgstr "ทั่วไป"
|
||||
|
||||
#: pretix/control/navigation.py
|
||||
#: pretix/control/templates/pretixcontrol/event/quick_setup.html
|
||||
@@ -17290,7 +17290,7 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "Payment"
|
||||
msgstr ""
|
||||
msgstr "การชำระเงิน"
|
||||
|
||||
#: pretix/control/navigation.py pretix/control/views/event.py
|
||||
#: pretix/control/views/subevents.py
|
||||
@@ -17300,18 +17300,18 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_subevent_list.html
|
||||
#: pretix/presale/templates/pretixpresale/organizers/index.html
|
||||
msgid "Tickets"
|
||||
msgstr ""
|
||||
msgstr "ตั๋ว"
|
||||
|
||||
#: pretix/control/navigation.py
|
||||
#: pretix/control/templates/pretixcontrol/event/tax.html
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html
|
||||
msgid "Taxes"
|
||||
msgstr ""
|
||||
msgstr "ภาษี"
|
||||
|
||||
#: pretix/control/navigation.py
|
||||
msgid "Invoicing"
|
||||
msgstr ""
|
||||
msgstr "การออกใบแจ้งหนี้"
|
||||
|
||||
#: pretix/control/navigation.py
|
||||
msgctxt "action"
|
||||
@@ -21506,19 +21506,19 @@ msgstr ""
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/oauth/authorized.html
|
||||
msgid "Permissions"
|
||||
msgstr ""
|
||||
msgstr "สิทธิ์การใช้งาน"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/oauth/authorized.html
|
||||
msgid "No applications have access to your pretix account."
|
||||
msgstr ""
|
||||
msgstr "ไม่มีแอปพลิเคชันใดเข้าถึงบัญชี pretix ของคุณ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/approve.html
|
||||
msgid "Approve order"
|
||||
msgstr ""
|
||||
msgstr "อนุมัติคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/approve.html
|
||||
msgid "Do you really want to approve this order?"
|
||||
msgstr ""
|
||||
msgstr "คุณแน่ใจหรือไม่ว่าต้องการอนุมัติคำสั่งซื้อนี้?"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/approve.html
|
||||
#: pretix/control/templates/pretixcontrol/order/cancel.html
|
||||
@@ -21529,29 +21529,32 @@ msgstr ""
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_cancel.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "No, take me back"
|
||||
msgstr ""
|
||||
msgstr "ไม่, ย้อนกลับ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/approve.html
|
||||
msgid "Yes, approve order"
|
||||
msgstr ""
|
||||
msgstr "ใช่, อนุมัติคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancel.html
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "Cancel order"
|
||||
msgstr ""
|
||||
msgstr "ยกเลิกคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancel.html
|
||||
#: pretix/control/templates/pretixcontrol/order/deny.html
|
||||
msgid "Do you really want to cancel this order? You cannot revert this action."
|
||||
msgstr ""
|
||||
"คุณแน่ใจหรือไม่ว่าต้องการยกเลิกคำสั่งซื้อนี้? การดำเนินการนี้ไม่สามารถย้อนกลับได้"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancel.html
|
||||
msgid ""
|
||||
"This will <strong>not</strong> automatically transfer the money back, but "
|
||||
"you will be offered options to refund the payment afterwards."
|
||||
msgstr ""
|
||||
"การดำเนินการนี้จะ <strong>ไม่</strong>คืนเงินโดยอัตโนมัติ แต่คุณจะมีตัวเลือกในการคืนเงินหลังจา"
|
||||
"กนี้"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancel.html
|
||||
#, python-format
|
||||
@@ -21560,15 +21563,17 @@ msgid ""
|
||||
"%(fee)s for this order, but for a cancellation performed by you, you need to "
|
||||
"set the cancellation fee here:"
|
||||
msgstr ""
|
||||
"ค่าธรรมเนียมการยกเลิกที่ตั้งค่าไว้สำหรับการยกเลิกด้วยตนเองจะอยู่ที่ %(fee)s สำหรับคำสั่งซื้อนี้ แต่สำ"
|
||||
"หรับการยกเลิกที่ดำเนินการโดยคุณ คุณจะต้องกำหนดค่าธรรมเนียมการยกเลิกที่นี่:"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancel.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "Yes, cancel order"
|
||||
msgstr ""
|
||||
msgstr "ใช่, ยกเลิกคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancellation_request_delete.html
|
||||
msgid "Ignore cancellation request"
|
||||
msgstr ""
|
||||
msgstr "เพิกเฉยต่อคำขอยกเลิก"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancellation_request_delete.html
|
||||
msgid ""
|
||||
@@ -21576,6 +21581,8 @@ msgid ""
|
||||
"informed automatically, but you will have the option to email them "
|
||||
"individually in the next step."
|
||||
msgstr ""
|
||||
"คุณแน่ใจหรือไม่ว่าต้องการลบคำขอยกเลิกนี้? ผู้ใช้จะไม่ได้รับการแจ้งเตือนโดยอัตโนมัติ แต่คุณจะมีตัวเลือก"
|
||||
"ในการส่งอีเมลถึงพวกเขาโดยตรงในขั้นตอนถัดไป"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/cancellation_request_delete.html
|
||||
msgid "Yes, delete request"
|
||||
@@ -21753,24 +21760,24 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/event/order_change_confirm.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position_change_confirm.html
|
||||
msgid "Perform changes"
|
||||
msgstr ""
|
||||
msgstr "ดำเนินการเปลี่ยนแปลง"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/change_contact.html
|
||||
#: pretix/control/templates/pretixcontrol/order/change_questions.html
|
||||
msgid "Change contact information"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงข้อมูลการติดต่อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/change_locale.html
|
||||
msgid "Change locale information"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงข้อมูลภาษาและภูมิภาค"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/change_locale.html
|
||||
msgid "This language will be used whenever emails are sent to the users."
|
||||
msgstr ""
|
||||
msgstr "ภาษานี้จะถูกใช้เมื่อใดก็ตามที่มีการส่งอีเมลไปยังผู้ใช้"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/change_questions.html
|
||||
msgid "Change order information"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงข้อมูลคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/change_questions.html
|
||||
#: pretix/control/templates/pretixcontrol/order/index.html
|
||||
@@ -21778,13 +21785,13 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
msgid "Invoice information"
|
||||
msgstr ""
|
||||
msgstr "ข้อมูลใบแจ้งหนี้"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/change_questions.html
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
msgid "(optional)"
|
||||
msgstr ""
|
||||
msgstr "(ระบุหรือไม่ก็ได้)"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/change_questions.html
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
@@ -21797,17 +21804,19 @@ msgstr "ต้องการข้อมูลเพิ่มเติม"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/delete.html
|
||||
msgid "Delete order"
|
||||
msgstr ""
|
||||
msgstr "ลบคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/delete.html
|
||||
msgid ""
|
||||
"Do you really want to delete this order? <strong>You really cannot revert "
|
||||
"this action and we can't either.</strong>"
|
||||
msgstr ""
|
||||
"คุณแน่ใจหรือไม่ว่าต้องการลบคำสั่งซื้อนี้? <strong>คุณจะไม่สามารถย้อนกลับการดำเนินการนี้ได้จริงๆ แล"
|
||||
"ะเราก็ไม่สามารถย้อนกลับได้เช่นกัน</strong>"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/delete.html
|
||||
msgid "Yes, delete order"
|
||||
msgstr ""
|
||||
msgstr "ใช่, ลบคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/deny.html
|
||||
msgid "Deny order"
|
||||
@@ -22256,12 +22265,13 @@ msgstr ""
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_cancel.html
|
||||
msgid "Cancel refund"
|
||||
msgstr ""
|
||||
msgstr "ยกเลิกการคืนเงิน"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_cancel.html
|
||||
msgid ""
|
||||
"Do you really want to cancel this refund? You cannot revert this action."
|
||||
msgstr ""
|
||||
"คุณแน่ใจหรือไม่ว่าต้องการยกเลิกการคืนเงินนี้? การดำเนินการนี้ไม่สามารถย้อนกลับได้"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_cancel.html
|
||||
msgid ""
|
||||
@@ -22269,19 +22279,22 @@ msgid ""
|
||||
"will just mark this transfer as aborted in pretix. This will also not "
|
||||
"reactivate the order, it will just allow you to choose a new refund method."
|
||||
msgstr ""
|
||||
"หากอยู่ในระหว่างการโอนเงินคืนแล้ว การดำเนินการนี้จะไม่สามารถระงับการโอนเงินได้ แต่จะเป็นเพียง"
|
||||
"การทำเครื่องหมายว่ารายการโอนนี้ถูกยกเลิกใน pretix เท่านั้น ทั้งนี้ จะไม่เป็นการเปิดใช้งานคำสั่งซื้อ"
|
||||
"อีกครั้ง แต่จะเปิดโอกาสให้คุณเลือกวิธีการคืนเงินรูปแบบใหม่ได้"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_cancel.html
|
||||
msgid "Yes, cancel refund"
|
||||
msgstr ""
|
||||
msgstr "ใช่, ยกเลิกการคืนเงิน"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_start.html
|
||||
msgid "Refund order"
|
||||
msgstr ""
|
||||
msgstr "คืนเงินคำสั่งซื้อ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "How should the refund be sent?"
|
||||
msgstr ""
|
||||
msgstr "ต้องการส่งเงินคืนด้วยวิธีใด?"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid ""
|
||||
@@ -22290,38 +22303,41 @@ msgid ""
|
||||
"created as pending refunds, which you can later mark as done once you have "
|
||||
"actually transferred the money back to the customer."
|
||||
msgstr ""
|
||||
"รายการชำระเงินใดที่คุณเลือกเป็นการคืนเงินอัตโนมัติ คำขอคืนเงินจะถูกส่งไปยังผู้ให้บริการชำระเงินที่เกี่"
|
||||
"ยวข้องทันที ส่วนการคืนเงินด้วยตนเองจะถูกสร้างเป็นรายการคืนเงินที่รอดำเนินการ ซึ่งคุณสามารถทำเครื่"
|
||||
"องหมายว่าเสร็จสิ้นได้ในภายหลัง หลังจากที่ได้โอนเงินคืนให้ลูกค้าเรียบร้อยแล้ว"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "Refund to original payment method"
|
||||
msgstr ""
|
||||
msgstr "คืนเงินไปยังช่องทางชำระเงินเดิม"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "Amount not refunded"
|
||||
msgstr ""
|
||||
msgstr "จำนวนเงินที่ไม่ได้รับการคืน"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "Refund amount"
|
||||
msgstr ""
|
||||
msgstr "จำนวนเงินที่คืน"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "Full amount"
|
||||
msgstr ""
|
||||
msgstr "จำนวนเงินเต็ม"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "This payment method does not support automatic refunds."
|
||||
msgstr ""
|
||||
msgstr "วิธีการชำระเงินนี้ไม่รองรับการคืนเงินอัตโนมัติ"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "Refund to a different payment method"
|
||||
msgstr ""
|
||||
msgstr "คืนเงินไปยังช่องทางชำระเงินอื่น"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "Recipient / options"
|
||||
msgstr ""
|
||||
msgstr "ผู้รับ / ตัวเลือก"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
msgid "Transfer to other order"
|
||||
msgstr ""
|
||||
msgstr "โอนไปยังคำสั่งซื้ออื่น"
|
||||
|
||||
#: pretix/control/templates/pretixcontrol/order/refund_choose.html
|
||||
#: pretix/control/templates/pretixcontrol/organizers/giftcard_create.html
|
||||
@@ -31172,48 +31188,48 @@ msgstr ""
|
||||
#: pretix/presale/forms/renderers.py
|
||||
msgctxt "form"
|
||||
msgid "is valid"
|
||||
msgstr ""
|
||||
msgstr "ใช้ได้"
|
||||
|
||||
#: pretix/presale/forms/renderers.py
|
||||
msgctxt "form"
|
||||
msgid "has errors"
|
||||
msgstr ""
|
||||
msgstr "มีข้อผิดพลาด"
|
||||
|
||||
#: pretix/presale/forms/renderers.py
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_voucher_form.html
|
||||
msgctxt "form"
|
||||
msgid "required"
|
||||
msgstr ""
|
||||
msgstr "จำเป็นต้องระบุ"
|
||||
|
||||
#: pretix/presale/ical.py
|
||||
#, python-brace-format
|
||||
msgid "Tickets: {url}"
|
||||
msgstr ""
|
||||
msgstr "ตั๋ว: {url}"
|
||||
|
||||
#: pretix/presale/ical.py
|
||||
#, python-brace-format
|
||||
msgid "Admission: {datetime}"
|
||||
msgstr ""
|
||||
msgstr "เวลาประตูเปิด: {datetime}"
|
||||
|
||||
#: pretix/presale/ical.py
|
||||
#, python-brace-format
|
||||
msgid "Organizer: {organizer}"
|
||||
msgstr ""
|
||||
msgstr "ผู้จัดงาน: {organizer}"
|
||||
|
||||
#: pretix/presale/ical.py
|
||||
#, python-brace-format
|
||||
msgid "{event} - {item}"
|
||||
msgstr ""
|
||||
msgstr "{event} - {item}"
|
||||
|
||||
#: pretix/presale/ical.py
|
||||
#, python-brace-format
|
||||
msgid "Start: {datetime}"
|
||||
msgstr ""
|
||||
msgstr "เริ่มต้น: {datetime}"
|
||||
|
||||
#: pretix/presale/ical.py
|
||||
#, python-brace-format
|
||||
msgid "End: {datetime}"
|
||||
msgstr ""
|
||||
msgstr "สิ้นสุด: {datetime}"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/base.html
|
||||
msgctxt "skip-to-main-nav"
|
||||
@@ -31355,23 +31371,23 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/organizers/customer_address_delete.html
|
||||
#: pretix/presale/templates/pretixpresale/organizers/customer_profile_delete.html
|
||||
msgid "Go back"
|
||||
msgstr ""
|
||||
msgstr "ย้อนกลับ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_base.html
|
||||
#, python-format
|
||||
msgid "Step %(current)s of %(total)s: %(label)s"
|
||||
msgstr ""
|
||||
msgstr "ขั้นตอนที่ %(current)s จาก %(total)s: %(label)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_base.html
|
||||
msgid "Checkout"
|
||||
msgstr ""
|
||||
msgstr "ชำระเงิน"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_base.html
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_cart_box.html
|
||||
msgid "Your cart"
|
||||
msgstr ""
|
||||
msgstr "ตระกร้าของคุณ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_base.html
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
@@ -31381,36 +31397,36 @@ msgstr "ตะกร้าสินค้าหมดเวลาแล้ว"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_base.html
|
||||
msgid "Show full cart"
|
||||
msgstr ""
|
||||
msgstr "แสดงตะกร้าสินค้าทั้งหมด"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_base.html
|
||||
#: pretix/presale/templates/pretixpresale/event/index.html
|
||||
msgid "Add tickets for a different date"
|
||||
msgstr ""
|
||||
msgstr "เพิ่มตั๋วสำหรับวันที่อื่น"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
msgid "Review order"
|
||||
msgstr ""
|
||||
msgstr "ตรวจสอบคำสั่งซื้อ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
msgid "Please review the details below and confirm your order."
|
||||
msgstr ""
|
||||
msgstr "โปรดตรวจสอบรายละเอียดด้านล่างและยืนยันคำสั่งซื้อของคุณ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
msgid "Add or remove tickets"
|
||||
msgstr ""
|
||||
msgstr "เพิ่มหรือลบตั๋ว"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
msgid "Please hang tight, we're finalizing your order!"
|
||||
msgstr ""
|
||||
msgstr "โปรดรอสักครู่ ระบบกำลังดำเนินการสรุปคำสั่งซื้อของคุณ!"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
msgid "Modify payment"
|
||||
msgstr ""
|
||||
msgstr "แก้ไขการชำระเงิน"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
msgid "Modify"
|
||||
msgstr ""
|
||||
msgstr "แก้ไข"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/checkout_confirm.html
|
||||
msgid "Modify invoice information"
|
||||
@@ -31901,8 +31917,7 @@ msgstr ""
|
||||
#, python-format
|
||||
msgid "One product"
|
||||
msgid_plural "%(num)s products"
|
||||
msgstr[0] ""
|
||||
msgstr[1] ""
|
||||
msgstr[0] "%(num)s รายการสินค้า"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/fragment_cart.html
|
||||
#, python-format
|
||||
@@ -32442,31 +32457,35 @@ msgid ""
|
||||
"your order later. We also sent you an email to the address you specified "
|
||||
"containing the link to this page."
|
||||
msgstr ""
|
||||
"โปรดบุ๊กมาร์กหรือบันทึกลิงก์ของหน้านี้ไว้ หากคุณต้องการเข้าถึงคำสั่งซื้อของคุณในภายหลัง นอกจากนี้ เรา"
|
||||
"ได้ส่งอีเมลพร้อมลิงก์สำหรับเข้าถึงหน้านี้ไปยังที่อยู่อีเมลที่คุณระบุไว้แล้ว"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid ""
|
||||
"Please save the following link if you want to access your order later. We "
|
||||
"also sent you an email to the address you specified containing the link."
|
||||
msgstr ""
|
||||
"โปรดบันทึกลิงก์ต่อไปนี้ไว้ หากคุณต้องการเข้าถึงคำสั่งซื้อของคุณในภายหลัง นอกจากนี้ เราได้ส่งอีเมลพร้อ"
|
||||
"มลิงก์ไปยังที่อยู่อีเมลที่คุณระบุไว้แล้ว"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position.html
|
||||
msgid "View in backend"
|
||||
msgstr ""
|
||||
msgstr "ดูในระบบหลังบ้าน"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#, python-format
|
||||
msgid "A payment of %(total)s is still pending for this order."
|
||||
msgstr ""
|
||||
msgstr "คำสั่งซื้อนี้ยังมียอดคงค้างชำระอยู่อีก %(total)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#, python-format
|
||||
msgid "Please complete your payment before %(date)s"
|
||||
msgstr ""
|
||||
msgstr "โปรดชำระเงินให้เสร็จสิ้นก่อนวันที่ %(date)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "Re-try payment or choose another payment method"
|
||||
msgstr ""
|
||||
msgstr "ลองชำระเงินอีกครั้ง หรือเลือกวิธีชำระเงินอื่น"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid ""
|
||||
@@ -32510,22 +32529,22 @@ msgstr ""
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position.html
|
||||
msgid "Change ordered items"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงรายการสินค้าที่สั่งซื้อ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position.html
|
||||
msgid "Change details"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงรายละเอียด"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid ""
|
||||
"You need to select a payment method above before you can request an invoice."
|
||||
msgstr ""
|
||||
msgstr "คุณต้องเลือกวิธีการชำระเงินด้านบนก่อน จึงจะสามารถขอใบแจ้งหนี้ได้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
msgid "Request invoice"
|
||||
msgstr ""
|
||||
msgstr "ขอใบแจ้งหนี้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "Your information"
|
||||
@@ -32533,41 +32552,42 @@ msgstr "ข้อมูลของคุณ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "Change your information"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงข้อมูลของคุณ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "Internal Reference"
|
||||
msgstr ""
|
||||
msgstr "รหัสอ้างอิงภายใน"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgctxt "action"
|
||||
msgid "Change or cancel your order"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงหรือยกเลิกคำสั่งซื้อของคุณ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgctxt "action"
|
||||
msgid "Change your order"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงคำสั่งซื้อของคุณ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgctxt "action"
|
||||
msgid "Cancel your order"
|
||||
msgstr ""
|
||||
msgstr "ยกเลิกคำสั่งซื้อของคุณ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid ""
|
||||
"If you want to make changes to the products you bought, you can click on the "
|
||||
"button to change your order."
|
||||
msgstr ""
|
||||
"หากคุณต้องการเปลี่ยนแปลงสินค้าที่ซื้อไว้ คุณสามารถคลิกที่ปุ่มเพื่อเปลี่ยนแปลงคำสั่งซื้อของคุณได้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "Change order"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงคำสั่งซื้อ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "You can request to cancel this order."
|
||||
msgstr ""
|
||||
msgstr "คุณสามารถยื่นขอยกเลิกคำสั่งซื้อนี้ได้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
@@ -32635,17 +32655,17 @@ msgstr ""
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order.html
|
||||
msgid "You can cancel this order using the following button."
|
||||
msgstr ""
|
||||
msgstr "คุณสามารถยกเลิกคำสั่งซื้อนี้ได้โดยใช้ปุ่มต่อไปนี้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
#, python-format
|
||||
msgid "Request cancellation: %(code)s"
|
||||
msgstr ""
|
||||
msgstr "ยื่นขอยกเลิก: %(code)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
#, python-format
|
||||
msgid "Cancel order: %(code)s"
|
||||
msgstr ""
|
||||
msgstr "ยกเลิกคำสั่งซื้อ: %(code)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid ""
|
||||
@@ -32653,12 +32673,16 @@ msgid ""
|
||||
"organizer will then decide on your request. If they approve, your order will "
|
||||
"be canceled and all tickets will be invalidated."
|
||||
msgstr ""
|
||||
"คุณสามารถยื่นขอยกเลิกคำสั่งซื้อของคุณได้ในหน้านี้ โดยผู้จัดงานจะพิจารณาคำขอของคุณ หากคำขอได้รับกา"
|
||||
"รอนุมัติ คำสั่งซื้อของคุณจะถูกยกเลิก และตั๋วทั้งหมดจะถูกยกเลิกใช้งาน"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid ""
|
||||
"If you cancel this order, all tickets will be invalidated and you can no "
|
||||
"longer use them. You cannot revert this action."
|
||||
msgstr ""
|
||||
"หากคุณยกเลิกคำสั่งซื้อนี้ ตั๋วทั้งหมดจะถูกยกเลิกใช้งาน และคุณจะไม่สามารถใช้งานตั๋วได้อีกต่อไป การดำเ"
|
||||
"นินการนี้ไม่สามารถย้อนกลับได้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
#, python-format
|
||||
@@ -32666,97 +32690,102 @@ msgid ""
|
||||
"If you want, you can request a refund for the full amount minus a "
|
||||
"cancellation fee of %(fee)s."
|
||||
msgstr ""
|
||||
"หากคุณต้องการ คุณสามารถยื่นขอคืนเงินเต็มจำนวนโดยหักค่าธรรมเนียมการยกเลิก %(fee)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "If you want, you can request a full refund."
|
||||
msgstr ""
|
||||
msgstr "หากคุณต้องการ คุณสามารถยื่นขอคืนเงินเต็มจำนวนได้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "Enter custom amount"
|
||||
msgstr ""
|
||||
msgstr "ระบุจำนวนเงินเอง"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "Refund amount:"
|
||||
msgstr ""
|
||||
msgstr "จำนวนเงินที่คืน:"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
#, python-format
|
||||
msgid "Your gift card will be valid until %(expiry_date)s."
|
||||
msgstr ""
|
||||
msgstr "บัตรของขวัญของคุณจะสามารถใช้งานได้จนถึงวันที่ %(expiry_date)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "I want the refund as a gift card for later purchases"
|
||||
msgstr ""
|
||||
msgstr "ฉันต้องการรับเงินคืนเป็นบัตรของขวัญสำหรับการซื้อในครั้งถัดไป"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "I want the refund to be sent to my original payment method"
|
||||
msgstr ""
|
||||
msgstr "ฉันต้องการรับเงินคืนผ่านช่องทางชำระเงินเดิม"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "The following payment methods will be used to refund the money to you:"
|
||||
msgstr ""
|
||||
msgstr "ช่องทางการชำระเงินต่อไปนี้จะถูกใช้เพื่อคืนเงินให้กับคุณ:"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_cancel.html
|
||||
msgid "Yes, request cancellation"
|
||||
msgstr ""
|
||||
msgstr "ใช่, ยื่นขอยกเลิก"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_change_confirm.html
|
||||
msgid "Please confirm the following changes to your order."
|
||||
msgstr ""
|
||||
msgstr "โปรดยืนยันการเปลี่ยนแปลงคำสั่งซื้อของคุณดังต่อไปนี้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_giftcard.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position_giftcard.html
|
||||
#, python-format
|
||||
msgid "Gift card: %(code)s"
|
||||
msgstr ""
|
||||
msgstr "บัตรของขวัญ: %(code)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
msgid "Modify order"
|
||||
msgstr ""
|
||||
msgstr "แก้ไขคำสั่งซื้อ"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
#, python-format
|
||||
msgid "Modify order: %(code)s"
|
||||
msgstr ""
|
||||
msgstr "แก้ไขคำสั่งซื้อ: %(code)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
msgid ""
|
||||
"Modifying your invoice address will not automatically generate a new "
|
||||
"invoice. Please contact us if you need a new invoice."
|
||||
msgstr ""
|
||||
"การแก้ไขที่อยู่สำหรับออกใบแจ้งหนี้จะไม่สร้างใบแจ้งหนี้ใหม่โดยอัตโนมัติ โปรดติดต่อเราหากคุณต้องการใบ"
|
||||
"แจ้งหนี้ใหม่"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_modify.html
|
||||
#: pretix/presale/templates/pretixpresale/event/position_modify.html
|
||||
msgid "Save changes"
|
||||
msgstr ""
|
||||
msgstr "บันทึกการเปลี่ยนแปลง"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
|
||||
msgid "Change payment method"
|
||||
msgstr ""
|
||||
msgstr "เปลี่ยนแปลงวิธีการชำระเงิน"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
|
||||
#, python-format
|
||||
msgid "Choose payment method: %(code)s"
|
||||
msgstr ""
|
||||
msgstr "เลือกวิธีการชำระเงิน: %(code)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
|
||||
msgid ""
|
||||
"Please note: If you change your payment method, your order total will change "
|
||||
"by the amount displayed to the right of each method."
|
||||
msgstr ""
|
||||
"ข้อควรจำ: หากคุณเปลี่ยนแปลงวิธีการชำระเงิน ยอดรวมคำสั่งซื้อของคุณจะเปลี่ยนแปลงตามจำนวนที่แสดง"
|
||||
"ด้านขวาของแต่ละวิธี"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
|
||||
msgid "There are no alternative payment providers available for this order."
|
||||
msgstr ""
|
||||
msgstr "ไม่มีผู้ให้บริการชำระเงินอื่นที่สามารถใช้งานได้สำหรับคำสั่งซื้อนี้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_confirm.html
|
||||
msgid "Please confirm the following payment details."
|
||||
msgstr ""
|
||||
msgstr "โปรดยืนยันรายละเอียดการชำระเงินดังต่อไปนี้"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/order_pay_confirm.html
|
||||
#, python-format
|
||||
msgid "Total: %(total)s"
|
||||
msgstr ""
|
||||
msgstr "ยอดรวม: %(total)s"
|
||||
|
||||
#: pretix/presale/templates/pretixpresale/event/payment_qr_codes.html
|
||||
msgid ""
|
||||
|
||||
@@ -372,10 +372,7 @@ def _render_badges(event: Event, positions: List[OrderPosition], opt: dict) -> T
|
||||
fg_pdf.append(buffer)
|
||||
new_num_pages = len(fg_pdf.pages)
|
||||
for i in range(new_num_pages - num_pages):
|
||||
bg_page = renderer.bg_pdf.pages[i]
|
||||
if bg_page.rotation != 0:
|
||||
bg_page.transfer_rotation_to_content()
|
||||
bg_pdf.add_page(bg_page)
|
||||
bg_pdf.add_page(renderer.bg_pdf.pages[i])
|
||||
num_pages = new_num_pages
|
||||
|
||||
return fg_pdf, bg_pdf, num_pages
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
@@ -0,0 +1,127 @@
|
||||
from rest_framework import viewsets
|
||||
from django.db import transaction
|
||||
from .styles import AVAILABLE_STYLES_DICT, AVAILABLE_PLATFORMS
|
||||
from .models import WalletLayout, WalletPlatformLayout
|
||||
from pretix.api.serializers.i18n import I18nAwareModelSerializer
|
||||
from django.core.exceptions import ValidationError
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from rest_framework import serializers
|
||||
import os
|
||||
|
||||
|
||||
class WalletPlatformLayoutSerializer(I18nAwareModelSerializer):
|
||||
platform = serializers.ChoiceField(
|
||||
choices=[p.identifier for p in AVAILABLE_PLATFORMS]
|
||||
)
|
||||
style = serializers.CharField(allow_null=True, required=False)
|
||||
file_settings = serializers.JSONField(default=dict, required=False)
|
||||
|
||||
class Meta:
|
||||
model = WalletPlatformLayout
|
||||
fields = ("platform", "style", "layout", "file_settings")
|
||||
|
||||
def validate_layout(self, value):
|
||||
if not isinstance(value, dict):
|
||||
raise ValidationError(_("Layout must be a dict"))
|
||||
return value
|
||||
|
||||
def validate(self, data):
|
||||
platform = data.get("platform")
|
||||
style = data.get("style")
|
||||
layout = data.get("layout")
|
||||
if platform and style and layout:
|
||||
platform_styles = AVAILABLE_STYLES_DICT[platform]
|
||||
|
||||
if data["style"] not in platform_styles:
|
||||
raise ValidationError(_("Invalid style"))
|
||||
style = platform_styles[data["style"]]
|
||||
|
||||
style = style(event=self.context["event"], layout=data["layout"])
|
||||
style.validate()
|
||||
data["file_settings"] = style.extract_file_settings(
|
||||
self.context["request"], data.get("file_settings", {})
|
||||
)
|
||||
|
||||
return data
|
||||
|
||||
def to_representation(self, instance):
|
||||
ret = super().to_representation(instance)
|
||||
ret["file_settings"] = {}
|
||||
for file_setting in instance.file_settings.all():
|
||||
try:
|
||||
url = file_setting.file.url
|
||||
except AttributeError:
|
||||
continue
|
||||
request = self.context["request"]
|
||||
ret["file_settings"][file_setting.key] = {
|
||||
"url": request.build_absolute_uri(url),
|
||||
"name": os.path.basename(file_setting.file.name).split('.', 1)[-1]
|
||||
}
|
||||
return ret
|
||||
|
||||
|
||||
class WalletLayoutSerializer(I18nAwareModelSerializer):
|
||||
platform_layouts = WalletPlatformLayoutSerializer(many=True)
|
||||
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("id", "name", "platform_layouts")
|
||||
read_only_fields = ("id",)
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
super().save(*args, **kwargs, event=self.context["event"])
|
||||
|
||||
def update(self, instance, validated_data):
|
||||
platform_layouts = validated_data.pop("platform_layouts")
|
||||
for layout in platform_layouts:
|
||||
if layout["style"]:
|
||||
file_settings = layout.pop("file_settings", {})
|
||||
obj, _ = instance.platform_layouts.update_or_create(
|
||||
platform=layout["platform"], defaults=layout
|
||||
)
|
||||
for key, file in file_settings.items():
|
||||
if not file:
|
||||
obj.file_settings.filter(key=key).delete()
|
||||
|
||||
elif file != "keep":
|
||||
file_setting, _ = obj.file_settings.get_or_create(
|
||||
key=key
|
||||
)
|
||||
file_setting.file.save(os.path.basename(file.name), file)
|
||||
|
||||
instance.platform_layouts.exclude(
|
||||
platform__in={
|
||||
layout["platform"]
|
||||
for layout in platform_layouts
|
||||
if layout["style"] is not None
|
||||
}
|
||||
).delete()
|
||||
return super().update(instance, validated_data)
|
||||
|
||||
|
||||
class WalletLayoutViewSet(viewsets.ModelViewSet):
|
||||
model = WalletLayout
|
||||
queryset = WalletLayout.objects.none()
|
||||
serializer_class = WalletLayoutSerializer
|
||||
permission = "event.settings.general:write"
|
||||
|
||||
def get_queryset(self):
|
||||
return self.request.event.wallet_layouts.all()
|
||||
|
||||
def get_serializer_context(self):
|
||||
ctx = super().get_serializer_context()
|
||||
ctx["event"] = self.request.event
|
||||
return ctx
|
||||
|
||||
@transaction.atomic()
|
||||
def perform_update(self, serializer):
|
||||
super().perform_update(serializer)
|
||||
serializer.instance.log_action(
|
||||
action="pretix.plugins.wallet.layout.changed",
|
||||
user=self.request.user,
|
||||
auth=self.request.auth,
|
||||
data=self.request.data,
|
||||
)
|
||||
@@ -0,0 +1,41 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.apps import AppConfig
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
|
||||
from pretix import __version__ as version
|
||||
|
||||
|
||||
class WalletApp(AppConfig):
|
||||
name = 'pretix.plugins.wallet'
|
||||
verbose_name = _("wallet")
|
||||
|
||||
class PretixPluginMeta:
|
||||
name = _("wallet")
|
||||
author = _("the pretix team")
|
||||
version = version
|
||||
category = 'FORMAT'
|
||||
description = _("Issue wallet passes for tickets (e.g. apple wallet, google wallet)")
|
||||
|
||||
def ready(self):
|
||||
from . import receivers # NOQA
|
||||
|
||||
@@ -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
|
||||
@@ -0,0 +1,98 @@
|
||||
# Generated by Django 5.2.13 on 2026-05-19 15:39
|
||||
|
||||
import django.db.models.deletion
|
||||
import pretix.base.models.base
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("pretixbase", "0297_outgoingmail"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("name", models.CharField(max_length=190)),
|
||||
(
|
||||
"event",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="wallet_layouts",
|
||||
to="pretixbase.event",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"abstract": False,
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WalletLayoutItem",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
(
|
||||
"item",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="walletlayout_assignments",
|
||||
to="pretixbase.item",
|
||||
),
|
||||
),
|
||||
(
|
||||
"layout",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="item_assignments",
|
||||
to="wallet.walletlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("item", "layout")},
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WalletPlatformLayout",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("platform", models.CharField(max_length=10)),
|
||||
("style", models.CharField(max_length=255)),
|
||||
("layout", models.JSONField(default=dict)),
|
||||
(
|
||||
"parent",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="platform_layouts",
|
||||
to="wallet.walletlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"unique_together": {("parent", "platform")},
|
||||
},
|
||||
bases=(models.Model, pretix.base.models.base.LoggingMixin),
|
||||
),
|
||||
]
|
||||
+41
@@ -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",
|
||||
),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,35 @@
|
||||
# Generated by Django 5.2.13 on 2026-08-11 15:39
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("wallet", "0002_alter_walletlayoutitem_unique_together_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WalletLayoutFileSetting",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True, primary_key=True, serialize=False
|
||||
),
|
||||
),
|
||||
("key", models.CharField()),
|
||||
("file", models.FileField(upload_to="")),
|
||||
(
|
||||
"layout",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="file_settings",
|
||||
to="wallet.walletplatformlayout",
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,102 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <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, OrderPosition
|
||||
from django_scopes import ScopedManager
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
|
||||
|
||||
class WalletLayout(LoggedModel):
|
||||
event = models.ForeignKey(
|
||||
'pretixbase.Event',
|
||||
on_delete=models.CASCADE,
|
||||
related_name='wallet_layouts'
|
||||
)
|
||||
name = models.CharField(
|
||||
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")
|
||||
|
||||
platform = models.CharField(max_length=10)
|
||||
style = models.CharField(max_length=255)
|
||||
layout = models.JSONField(default=dict)
|
||||
|
||||
objects = ScopedManager(organizer='parent__event__organizer')
|
||||
|
||||
class Meta:
|
||||
unique_together = (('parent', 'platform'),)
|
||||
|
||||
@property
|
||||
def pass_layout(self):
|
||||
from pretix.plugins.wallet.styles import get_style
|
||||
|
||||
style = get_style(self.platform, self.style)
|
||||
if style:
|
||||
file_settings = {fs.key: fs.file for fs in self.file_settings.all()}
|
||||
return style(event=self.parent.event, layout=self.layout, file_settings=file_settings)
|
||||
else:
|
||||
raise RuntimeError(f"Style {self.platform}.{self.style} not found")
|
||||
|
||||
class WalletLayoutItem(models.Model):
|
||||
item = models.OneToOneField('pretixbase.Item', null=True, blank=True, related_name='walletlayout',
|
||||
on_delete=models.CASCADE)
|
||||
layout = models.ForeignKey(WalletLayout, on_delete=models.CASCADE, related_name='item_assignments')
|
||||
|
||||
def clean(self):
|
||||
if self.item.event != self.layout.event:
|
||||
raise ValidationError("cannot bind layout to item of different event")
|
||||
|
||||
class WalletLayoutFileSetting(models.Model):
|
||||
layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.CASCADE, related_name="file_settings")
|
||||
key = models.CharField()
|
||||
file = models.FileField()
|
||||
|
||||
# smth like this for apple, lets see what the best architecture for google will be
|
||||
# class AppleWalletPass(models.Model):
|
||||
# platform_layout = models.ForeignKey(WalletPlatformLayout, on_delete=models.PROTECT)
|
||||
# order_position = models.ForeignKey(OrderPosition, on_delete=models.PROTECT)
|
||||
# content = models.BinaryField()
|
||||
# updated_at = models.DateTimeField(null=True, auto_now=True)
|
||||
# prob smth like this
|
||||
# class GoogleWalletObject(models.Model):
|
||||
# type = models.CharField(choices=["CLASS", "OBJECT"])
|
||||
# identifier = models.CharField()
|
||||
# data = models.JSONField()
|
||||
@@ -0,0 +1,421 @@
|
||||
from collections.abc import Callable
|
||||
from functools import cached_property
|
||||
from typing import Any
|
||||
|
||||
from tests.api.test_items import order_position
|
||||
from .signals import (
|
||||
register_wallet_text_placeholders,
|
||||
register_wallet_image_placeholders,
|
||||
)
|
||||
from django.core.files import File
|
||||
from django.dispatch import receiver
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.templatetags.static import static
|
||||
from django.utils.translation import gettext_lazy as _
|
||||
from i18nfield.strings import LazyI18nString
|
||||
|
||||
|
||||
class BaseWalletPlaceholder:
|
||||
"""
|
||||
This is the base class for all wallet placeholders.
|
||||
"""
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
"""
|
||||
A a set of all attribute names that need to be contained in the base context so that this placeholder is available.
|
||||
"""
|
||||
return set()
|
||||
|
||||
@property
|
||||
def identifier(self) -> str:
|
||||
"""The unique identifier of this placeholder"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def label(self) -> LazyI18nString:
|
||||
"""The human readable name of this placeholder"""
|
||||
raise NotImplementedError()
|
||||
|
||||
@property
|
||||
def control_label(self) -> LazyI18nString:
|
||||
"""
|
||||
The human readable name of this placeholder shown in the backend.
|
||||
|
||||
Defaults to `label`
|
||||
"""
|
||||
return self.label
|
||||
|
||||
def render(self, **context):
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context):
|
||||
raise NotImplementedError()
|
||||
|
||||
|
||||
class BaseWalletTextPlaceholder(BaseWalletPlaceholder):
|
||||
def render(self, **context) -> str | None:
|
||||
"""
|
||||
This method is called to generate the text that is being shown on the pass.
|
||||
You will be passed the keyword arguments specified in ``required_context``.
|
||||
You are expected to return a plain-text string.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context) -> str:
|
||||
"""
|
||||
This method is called to generate a text to be used in previews.
|
||||
|
||||
You will be passed sample instances of the arguments specified in ``required_context``.
|
||||
If those instances contain all data needed, you do not need to implement this.
|
||||
"""
|
||||
sample = self.render(**context)
|
||||
if sample is None:
|
||||
raise RuntimeError("`render` returned None when rendering a sample")
|
||||
return sample
|
||||
|
||||
|
||||
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
|
||||
def render(self, **context) -> File | None:
|
||||
"""
|
||||
This method is called to generate the image that is being shown on the pass.
|
||||
You will be passed the keyword arguments specified in ``required_context``.
|
||||
You are expected to return a `File` object.
|
||||
"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def render_sample(self, **context) -> str | None:
|
||||
"""
|
||||
This method is called to generate a text to be used in previews.
|
||||
|
||||
You will be passed sample instances of the arguments specified in ``required_context``.
|
||||
You are expected to return a URL to a sample image or `None` if no sample can be shown.
|
||||
"""
|
||||
return None
|
||||
|
||||
|
||||
class FunctionalWalletTextPlaceholder(BaseWalletTextPlaceholder):
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: LazyI18nString,
|
||||
args: set[str],
|
||||
func: Callable[..., str | None],
|
||||
sample: None | str | Callable[..., str] = None,
|
||||
):
|
||||
self._identifier = identifier
|
||||
self._label = label
|
||||
self._args = args
|
||||
self.render = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self._label
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
return self._args
|
||||
|
||||
def render_sample(self, **context) -> str:
|
||||
if isinstance(self._sample, Callable):
|
||||
return self._sample(**context)
|
||||
elif self._sample:
|
||||
return self._sample
|
||||
else:
|
||||
return super().render_sample(**context)
|
||||
|
||||
|
||||
class FunctionalWalletImagePlaceholder(BaseWalletImagePlaceholder):
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: LazyI18nString,
|
||||
args: set[str],
|
||||
func: Callable[..., File | None],
|
||||
sample: None | str | Callable[..., str] = None,
|
||||
):
|
||||
self._identifier = identifier
|
||||
self._label = label
|
||||
self._args = args
|
||||
self.render = func
|
||||
self._sample = sample
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
return self._args
|
||||
|
||||
@property
|
||||
def identifier(self):
|
||||
return self._identifier
|
||||
|
||||
@property
|
||||
def label(self):
|
||||
return self._label
|
||||
|
||||
def render_sample(self, **context) -> str | None:
|
||||
if isinstance(self._sample, Callable):
|
||||
return self._sample(**context)
|
||||
return self._sample
|
||||
|
||||
|
||||
class MissingContextException(Exception):
|
||||
pass
|
||||
|
||||
|
||||
class PlaceholderContextTransformation:
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
"""
|
||||
A a set of all attribute names that need to be contained in the base context for this transformation to function.
|
||||
"""
|
||||
return set()
|
||||
|
||||
@property
|
||||
def generated_context_name(self) -> str:
|
||||
"""The name of the context arg this transformation produces"""
|
||||
raise NotImplementedError()
|
||||
|
||||
def transform(self, context):
|
||||
raise NotImplementedError("`transform` not implemented")
|
||||
|
||||
|
||||
class FunctionalContextTransformation(PlaceholderContextTransformation):
|
||||
def __init__(self, context_name: str, context_args: set[str], func: Callable):
|
||||
self.context_args = context_args
|
||||
self.context_name = context_name
|
||||
self.transform = func
|
||||
|
||||
@property
|
||||
def required_context(self) -> set[str]:
|
||||
return self.context_args
|
||||
|
||||
@property
|
||||
def generated_context_name(self) -> str:
|
||||
return self.context_name
|
||||
|
||||
def get_available_context(initial_context: set[str], transformations: list[PlaceholderContextTransformation]):
|
||||
used_trans = set()
|
||||
available_context = set(initial_context)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for trans in transformations:
|
||||
if trans in used_trans:
|
||||
continue
|
||||
|
||||
if trans.required_context <= available_context:
|
||||
used_trans.add(trans)
|
||||
if trans.generated_context_name not in available_context:
|
||||
available_context.add(trans.generated_context_name)
|
||||
changed = True
|
||||
|
||||
return available_context
|
||||
|
||||
def get_transformed_context(context_args: dict[str, Any], transformations: list[PlaceholderContextTransformation]):
|
||||
used_trans = set()
|
||||
transformed_context_args = dict(context_args)
|
||||
changed = True
|
||||
while changed:
|
||||
changed = False
|
||||
for trans in transformations:
|
||||
if trans in used_trans:
|
||||
continue
|
||||
|
||||
if trans.required_context <= transformed_context_args.keys():
|
||||
used_trans.add(trans)
|
||||
if trans.generated_context_name not in transformed_context_args.keys():
|
||||
transformation_context = {
|
||||
k: v
|
||||
for k, v in transformed_context_args.items()
|
||||
if k in trans.required_context
|
||||
}
|
||||
transformed_context_args[trans.generated_context_name] = trans.transform(**transformation_context)
|
||||
changed = True
|
||||
|
||||
return transformed_context_args
|
||||
|
||||
class WalletPlaceholderRenderer:
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
transformations: list[PlaceholderContextTransformation] = None,
|
||||
**kwargs,
|
||||
):
|
||||
self.context = kwargs
|
||||
self.cache = {}
|
||||
self.transformations = list(transformations) if transformations else []
|
||||
|
||||
@cached_property
|
||||
def _get_transformed_context(self):
|
||||
return get_transformed_context(self.context, self.transformations)
|
||||
|
||||
def _get_placeholder_context(self, placeholder: BaseWalletPlaceholder):
|
||||
context = self._get_transformed_context
|
||||
missing_context = placeholder.required_context - context.keys()
|
||||
if missing_context:
|
||||
raise MissingContextException(
|
||||
f"Missing context args for '{placeholder.identifier}': {', '.join(missing_context)}"
|
||||
)
|
||||
|
||||
return {
|
||||
k: v
|
||||
for k, v in context.items()
|
||||
if k in placeholder.required_context
|
||||
}
|
||||
|
||||
def is_available(self, placeholder: BaseWalletPlaceholder, context_args: set[str]):
|
||||
available_context = get_available_context(context_args, self.transformations)
|
||||
missing_context = placeholder.required_context - available_context
|
||||
return not missing_context
|
||||
|
||||
def render_placeholder(self, placeholder: BaseWalletPlaceholder):
|
||||
if placeholder.identifier in self.cache:
|
||||
return self.cache[placeholder.identifier]
|
||||
|
||||
value = self.cache[placeholder.identifier] = placeholder.render(
|
||||
**self._get_placeholder_context(placeholder)
|
||||
)
|
||||
return value
|
||||
|
||||
def render_sample(self, placeholder: BaseWalletPlaceholder):
|
||||
return placeholder.render_sample(**self._get_placeholder_context(placeholder))
|
||||
|
||||
|
||||
def get_wallet_placeholder_renderer(**kwargs):
|
||||
return WalletPlaceholderRenderer(**kwargs, transformations=[
|
||||
# TODO: make this extendable via signal?
|
||||
FunctionalContextTransformation(
|
||||
"order", {"order_position"}, lambda order_position: order_position.order
|
||||
),
|
||||
FunctionalContextTransformation(
|
||||
"event", {"order"}, lambda order: order.event
|
||||
),
|
||||
FunctionalContextTransformation(
|
||||
"item", {"order_position"}, lambda order_position: order_position.item
|
||||
)
|
||||
])
|
||||
|
||||
|
||||
def get_wallet_placeholders(event) -> dict[str, dict[str, BaseWalletPlaceholder]]:
|
||||
placeholders = {
|
||||
"text": {
|
||||
v.identifier: v
|
||||
for r, vs in register_wallet_text_placeholders.send(sender=event)
|
||||
for v in vs
|
||||
},
|
||||
"image": {
|
||||
v.identifier: v
|
||||
for r, vs in register_wallet_image_placeholders.send(sender=event)
|
||||
for v in vs
|
||||
},
|
||||
}
|
||||
return placeholders
|
||||
|
||||
|
||||
def get_static_file(name) -> File | None:
|
||||
path: str | None = finders.find(name) # type: ignore
|
||||
if not path:
|
||||
return
|
||||
return File(open(path, "rb"))
|
||||
|
||||
|
||||
@receiver(
|
||||
register_wallet_text_placeholders,
|
||||
dispatch_uid="plugin_wallet_register_wallet_text_placeholders",
|
||||
)
|
||||
def base_text_placeholders(sender, **kwargs):
|
||||
return [
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"name",
|
||||
LazyI18nString.from_gettext("Event Name"),
|
||||
{"event"},
|
||||
lambda event: event.name,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"event_slug",
|
||||
LazyI18nString.from_gettext("Event Slug"),
|
||||
{"event"},
|
||||
lambda event: event.slug,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"order",
|
||||
LazyI18nString.from_gettext("Order Code"),
|
||||
{"order"},
|
||||
lambda order: order.code,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"total",
|
||||
LazyI18nString.from_gettext("Order Total"),
|
||||
{"event", "order"},
|
||||
lambda event, order: money_filter(order.total, event.currency),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"order_email",
|
||||
LazyI18nString.from_gettext("Order Email"),
|
||||
{"order"},
|
||||
lambda order: order.email,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"price",
|
||||
LazyI18nString.from_gettext("Item Price"),
|
||||
{"event", "order_position"},
|
||||
lambda event, order_position: money_filter(
|
||||
order_position.price, event.currency
|
||||
),
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"secret",
|
||||
LazyI18nString.from_gettext("Order Secret (QR-Code-Content)"),
|
||||
{"order_position"},
|
||||
lambda order_position: order_position.secret,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"item",
|
||||
LazyI18nString.from_gettext("Product name"),
|
||||
{"item"},
|
||||
lambda item: item.name,
|
||||
),
|
||||
FunctionalWalletTextPlaceholder(
|
||||
"item_description",
|
||||
LazyI18nString.from_gettext("Product description"),
|
||||
{"item"},
|
||||
lambda item: item.description,
|
||||
),
|
||||
]
|
||||
|
||||
|
||||
@receiver(
|
||||
register_wallet_image_placeholders,
|
||||
dispatch_uid="plugin_wallet_register_wallet_image_placeholders",
|
||||
)
|
||||
def base_image_placeholders(sender, **kwargs):
|
||||
return [
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"poweredby",
|
||||
LazyI18nString.from_gettext("Logo"),
|
||||
set(),
|
||||
# TODO: replace with paths not from another plugin
|
||||
lambda: get_static_file("pretix_passbook/logo.png"),
|
||||
static("pretix_passbook/logo.png"),
|
||||
),
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"poweredby_icon",
|
||||
LazyI18nString.from_gettext("Icon"),
|
||||
set(),
|
||||
lambda: get_static_file("pretix_passbook/icon.png"),
|
||||
static("pretix_passbook/icon.png"),
|
||||
),
|
||||
FunctionalWalletImagePlaceholder(
|
||||
"example_no_preview",
|
||||
LazyI18nString.from_gettext("Image with no preview"),
|
||||
set(),
|
||||
lambda: get_static_file("pretix_passbook/icon.png"),
|
||||
),
|
||||
# TODO: Image upload
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from pretix.base.signals import register_ticket_outputs, register_global_settings, EventPluginSignal
|
||||
from .ticketoutput import OUTPUTS
|
||||
|
||||
def connect_signals():
|
||||
for output in OUTPUTS:
|
||||
# DIY functools.partial to make get_defining_app happy
|
||||
def get_register_func(o):
|
||||
def register(sender, **kwargs):
|
||||
return o
|
||||
return register
|
||||
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()
|
||||
@@ -0,0 +1,39 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
|
||||
from pretix.base.signals import EventPluginSignal
|
||||
|
||||
register_wallet_text_placeholders = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to get all known wallet placeholders. Receivers should return
|
||||
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletTextPlaceholder.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
|
||||
register_wallet_image_placeholders = EventPluginSignal()
|
||||
"""
|
||||
This signal is sent out to get all known wallet placeholders. Receivers should return
|
||||
an list of subclasses of pretix.plugins.wallet.placeholders.BaseWalletImagePlaceholder.
|
||||
|
||||
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
|
||||
"""
|
||||
@@ -0,0 +1,107 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, ref, watchEffect } from "vue";
|
||||
import StyleSettings from "./style-settings.vue";
|
||||
import Select from "./input/select.vue";
|
||||
import Input from "./input/input.vue";
|
||||
import PassPreview from "./preview/pass-preview.vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
function openForm(url: string, data: Record<string, string|object>) {
|
||||
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;
|
||||
let value = data[key];
|
||||
if (value instanceof Object) {
|
||||
value = JSON.stringify(data[key]);
|
||||
}
|
||||
input.value = value
|
||||
form.appendChild(input);
|
||||
}
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
document.body.removeChild(form);
|
||||
}
|
||||
|
||||
async function openPreview(e: Event) {
|
||||
e.preventDefault();
|
||||
openForm("../../preview/", {
|
||||
csrfmiddlewaretoken: store.csrfToken,
|
||||
id: store.layoutId,
|
||||
...(await store.serializeCurrentPlatformLayout())
|
||||
});
|
||||
}
|
||||
|
||||
const platformChoices = computed(() => {
|
||||
return [
|
||||
[null, "Do not generate pass"],
|
||||
...Object.values(store.platform.styles).map((x) => [x.identifier, x.name]),
|
||||
];
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
// TODO: add :key for all `v-for`s
|
||||
// TODO: i18n textfields
|
||||
// TODO: proper spinner
|
||||
|
||||
template(v-if="!store.loaded") {{ gettext("Loading...") }}
|
||||
form(v-else @submit.prevent="store.save")
|
||||
.form-group
|
||||
Input(label="Name" v-model="store.name")
|
||||
nav
|
||||
ul.nav.nav-tabs
|
||||
li(v-for="platform in store.platforms" :class="{'active': store.platform.identifier === platform.identifier}")
|
||||
a(role="tab" @click="store.setPlatform(platform.identifier)") {{ platform.name }}
|
||||
.tabbed-form.tab-content
|
||||
.tab-pane.active.row
|
||||
.col-md-6.col-lg-8
|
||||
Select.form-group(label="Style" :modelValue="store.style?.identifier || null" @update:modelValue="store.setStyle" :choices="platformChoices")
|
||||
StyleSettings(v-if="!!store.style")
|
||||
.col-md-6.col-lg-4
|
||||
.panel.panel-default
|
||||
.panel-heading Preview
|
||||
.panel-body
|
||||
div(v-if="!!store.style?.preview_layout")
|
||||
span.text-muted {{ gettext("The preview below is only a rough representation of what the pass might look like. Please check the generated pass.") }}
|
||||
div(style="display: grid; gap: 1em; grid-template-columns: repeat(auto-fit, minmax(auto, 360px));")
|
||||
PassPreview(v-for="layout in store.style.preview_layout" :layout="layout")
|
||||
div(v-else) Preview not supported
|
||||
//- pre(v-if="store.currentPlatformLayout.style")
|
||||
//- code {{ store.currentPlatformStyles[store.currentPlatformLayout.style] }}
|
||||
//- pre
|
||||
//- code {{ store.walletLayout }}
|
||||
.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>
|
||||
|
||||
<style lang="css">
|
||||
.walletsettings-panel .panel-heading {
|
||||
.checkbox {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: inline-block;
|
||||
input[type="checkbox"] {
|
||||
margin-top: 0;
|
||||
margin-right: 5px;
|
||||
margin-left: 0px;
|
||||
position: relative;
|
||||
top: 1px;
|
||||
}
|
||||
label {
|
||||
padding-left: 0;
|
||||
}
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
}>()
|
||||
const modelValue = defineModel<boolean|null>();
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.checkbox
|
||||
label
|
||||
input(:id="id" v-model="modelValue" v-bind="$attrs" type="checkbox")
|
||||
| {{ props.label }}
|
||||
</template>
|
||||
@@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { ref, useId, watchEffect } from "vue";
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false,
|
||||
});
|
||||
const emit = defineEmits<{change: [File]}>()
|
||||
const props = defineProps<{
|
||||
label?: I18nString;
|
||||
errors?: string[];
|
||||
help_text?: string;
|
||||
filename?: string;
|
||||
current_url?: string;
|
||||
}>();
|
||||
const id = useId();
|
||||
function onChange(e) {
|
||||
emit("change", (e.target as HTMLInputElement).files[0])
|
||||
}
|
||||
|
||||
// Reset input field if a url is provided
|
||||
const inputRef = ref<HTMLInputElement>();
|
||||
watchEffect(() => {
|
||||
if (props.current_url && inputRef.value?.value) {
|
||||
inputRef.value.value = '';
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.form-group.row
|
||||
label.control-label.col-md-3(:for="id", v-if="!!label") {{ label }}
|
||||
br(v-if="!$attrs.required")
|
||||
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
|
||||
div.col-md-9
|
||||
template(v-if="!!current_url")
|
||||
| {{ gettext("Currently") + ': ' }}
|
||||
a(:href="current_url") {{ filename }}
|
||||
| {{ " " }}
|
||||
button.btn.btn-sm(@click.prevent="() => {console.log('clear'); emit('change', null)}") {{ gettext("Clear") }}
|
||||
//- br
|
||||
//- a(:href="modelValue" data-lightbox="input")
|
||||
//- img.thumb-img(:src="modelValue")
|
||||
br
|
||||
| {{ gettext("Change") + ': ' }}
|
||||
input(:id="id" @change="onChange" v-bind="$attrs" type="file" style="display: inline" ref="inputRef")
|
||||
.help-block(v-if="!!help_text") {{ help_text }}
|
||||
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.thumb-img {
|
||||
max-height: 100px;
|
||||
max-width: 200px;
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { inject, watchEffect } from 'vue'
|
||||
import { StoreKey } from "../../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
errors?: string[],
|
||||
}>();
|
||||
|
||||
const modelValue = defineModel<Record<string, string> | string>();
|
||||
watchEffect(() => {
|
||||
if (typeof modelValue.value === "string") {
|
||||
const oldVal = modelValue.value;
|
||||
modelValue.value = Object.fromEntries(Object.keys(store.locales).map((x): [string, string] => [x, oldVal]))
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
input.form-control(v-for="(human_readable, locale) in store.locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="human_readable")
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
@@ -0,0 +1,28 @@
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const {label, errors, type = "text"} = defineProps<{
|
||||
label?: I18nString,
|
||||
errors?: string[],
|
||||
type?: string
|
||||
help_text?: string,
|
||||
}>()
|
||||
const modelValue = defineModel<string|null>();
|
||||
const id = useId()
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
label.control-label(:for="id", v-if="label") {{ label }}
|
||||
br(v-if="!$attrs.required")
|
||||
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
|
||||
div
|
||||
input(:id="id" v-model="modelValue" v-bind="$attrs" :type="type" :class="{'form-control': type == 'text'}")
|
||||
.help-block(v-if="!!help_text") {{ help_text }}
|
||||
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
<script setup lang="ts">
|
||||
import { useId, watchEffect } from 'vue'
|
||||
|
||||
defineOptions({
|
||||
inheritAttrs: false
|
||||
})
|
||||
|
||||
const props = defineProps<{
|
||||
label?: string
|
||||
choices: Array<[string, string]>
|
||||
errors?: string[],
|
||||
class?: string
|
||||
}>()
|
||||
const modelValue = defineModel<string|null>();
|
||||
const id = useId()
|
||||
|
||||
watchEffect(() => {
|
||||
if (props.choices.length === 1) {
|
||||
modelValue.value = props.choices[0][0]
|
||||
} else if (props.choices.length < 1) {
|
||||
modelValue.value = null
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
template(v-if="choices.length >= 1" :class="props.class")
|
||||
label.control-label(v-if="props.label" :for="id") {{ props.label }}
|
||||
select.form-control(:id="id" v-model="modelValue" v-bind="$attrs" required)
|
||||
option(v-for="choice in props.choices" :key="choice[0]" :value="choice[0]") {{ choice[1] }}
|
||||
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
|
||||
</template>
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, watchEffect } from "vue";
|
||||
import Select from "./input/select.vue";
|
||||
import Checkbox from "./input/checkbox.vue";
|
||||
import I18nInput from "./input/i18ninput.vue";
|
||||
import TextContent from "./text-content.vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: PlaceholderFieldGroupDefinition;
|
||||
overflows: FieldGroupDefinition[];
|
||||
}>();
|
||||
const fieldConfig = defineModel<PlaceholderFieldGroupConfig>({
|
||||
required: true,
|
||||
});
|
||||
|
||||
const overflowOptions = computed((): Array<[string | null, string]> => {
|
||||
if (props.overflows.length) {
|
||||
return [
|
||||
...props.overflows.map((x): [string, string] => [x.identifier, x.name]),
|
||||
[null, "Do not overflow"],
|
||||
];
|
||||
} else {
|
||||
return [];
|
||||
}
|
||||
});
|
||||
|
||||
function addVariable() {
|
||||
fieldConfig.value.entries.push({ type: "placeholder", label: "" });
|
||||
}
|
||||
|
||||
watchEffect(() => {
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {
|
||||
overflow: null,
|
||||
entries: JSON.parse(JSON.stringify(props.fieldgroup.default_entries)),
|
||||
active:
|
||||
props.fieldgroup.required ||
|
||||
props.fieldgroup.default_entries.length > 0,
|
||||
};
|
||||
}
|
||||
if (fieldConfig.value && !fieldConfig.value.entries) {
|
||||
fieldConfig.value.entries = JSON.parse(
|
||||
JSON.stringify(props.fieldgroup.default_entries),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
const placeholderChoices = computed(() => {
|
||||
const availableContext = new Set(props.fieldgroup.context_args);
|
||||
const choices = Object.entries(store.variables.text)
|
||||
.filter(([_, { required_context }]) =>
|
||||
new Set(required_context).isSubsetOf(availableContext),
|
||||
)
|
||||
.map(([k, v]): [string, string] => [k, v.label]);
|
||||
choices.push(["other", gettext("Other…")]);
|
||||
return choices;
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.panel.panel-default.walletsettings-panel
|
||||
.panel-heading
|
||||
h3.panel-title {{ fieldgroup.name }}
|
||||
.panel-body(v-if="fieldConfig")
|
||||
.form-group()
|
||||
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
|
||||
h4 {{ gettext("Content") }}
|
||||
table.table.table-hover
|
||||
thead
|
||||
tr
|
||||
th.col-md-5(v-if="fieldgroup.display == 'with_label'") {{ gettext('Label') }}
|
||||
th(:class="'col-md-' + (fieldgroup.display == 'with_label' ? '6' : '11')") {{ gettext('Content') }}
|
||||
th.col-xs-1
|
||||
tbody
|
||||
tr(v-for="n, i in fieldConfig.entries.length" :key="i")
|
||||
td(v-if="fieldgroup.display == 'with_label'")
|
||||
.i18n-form-group
|
||||
I18nInput(v-model="fieldConfig.entries[n - 1].label")
|
||||
td
|
||||
TextContent(v-if='fieldgroup.content_type == "text"'
|
||||
v-model="fieldConfig.entries[n - 1]"
|
||||
:placeholderChoices="placeholderChoices")
|
||||
Select(v-else-if='fieldgroup.content_type == "image"'
|
||||
v-model="fieldConfig.entries[n - 1].content"
|
||||
:choices="Object.entries(store.variables.image).map(([k, v]) => [k, v.label])"
|
||||
)
|
||||
td.text-right
|
||||
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n - 1, 1)")
|
||||
i.fa.fa-trash
|
||||
span.sr-only {{ gettext('Delete') }}
|
||||
|
||||
button.btn.btn-default(type="button" @click="addVariable")
|
||||
i.fa.fa-plus
|
||||
| {{ gettext("Add field") }}
|
||||
Select(:label="gettext('Overflow to …')" :choices="overflowOptions" v-model="fieldConfig.overflow")
|
||||
</template>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import { watchEffect } from 'vue';
|
||||
import Input from './input/input.vue';
|
||||
import Checkbox from './input/checkbox.vue';
|
||||
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const props = defineProps<{
|
||||
fieldgroup: FieldGroupDefinition;
|
||||
}>();
|
||||
const fieldConfig = defineModel<PredefinedFieldGroupConfig>({ required: true });
|
||||
|
||||
watchEffect(() => {
|
||||
if (!fieldConfig.value) {
|
||||
fieldConfig.value = {active: props.fieldgroup.required};
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.panel.panel-default.walletsettings-panel
|
||||
.panel-heading
|
||||
h3.panel-title.form-inline
|
||||
.form-group
|
||||
Checkbox(v-if="!!fieldConfig" :label="fieldgroup.name" v-model="fieldConfig.active")
|
||||
.panel-body(v-if="!!fieldgroup.description")
|
||||
.form-group
|
||||
span.text-muted {{ fieldgroup.description }}
|
||||
</template>
|
||||
+25
@@ -0,0 +1,25 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import { i18nstringLocalize } from "../../helpers";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
label?: I18nString;
|
||||
content: I18nString;
|
||||
content_type: FieldContentType;
|
||||
display: FieldGroupDisplay;
|
||||
display_class?: string | string[];
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-item
|
||||
div.fieldgroup-item-qrcode(v-if="display == 'code'") {{ label }}
|
||||
|
||||
template(v-else)
|
||||
div.fieldgroup-label.nowrap(v-if="display == 'with_label'") {{ i18nstringLocalize(label) }}
|
||||
div.nowrap.content(v-if="content_type == 'text'" :class="display_class") {{ i18nstringLocalize(content) }}
|
||||
img.fieldgroup-item-image(v-else-if="content_type == 'image' && content" :src="i18nstringLocalize(content)")
|
||||
</template>
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import PlaceholderFieldgroupPreview from "./placeholder-fieldgroup-preview.vue";
|
||||
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
config: PreviewFieldgroup;
|
||||
}>();
|
||||
|
||||
const style_def = computed(() => {
|
||||
return Object.fromEntries(
|
||||
store.styles[
|
||||
store.layout.style
|
||||
].fieldgroups.map((x) => [x.identifier, x]),
|
||||
)[props.config.fieldgroup];
|
||||
});
|
||||
|
||||
function focusGroup() {
|
||||
// TODO: highlight group? or change ui concept completely
|
||||
const elem = document.getElementById('fieldgroup-' + props.config.fieldgroup)
|
||||
elem && elem.scrollIntoView()
|
||||
}
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
PlaceholderFieldgroupPreview(v-if="style_def && style_def.type == 'placeholder'" :config="config" :style_def="style_def" @click="focusGroup")
|
||||
PredefinedFieldgroupPreview(v-else-if="style_def && style_def.type == 'predefined'" :config="config" :style_def="style_def" @click="focusGroup")
|
||||
</template>
|
||||
+93
@@ -0,0 +1,93 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from "vue";
|
||||
import { StoreKey } from "../../walletStore.js";
|
||||
import RowPreview from "./row-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{ layout: Array<PreviewLayout> }>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.pass-container
|
||||
div.pass-content
|
||||
RowPreview(v-for="row of layout" :config="row")
|
||||
</template>
|
||||
|
||||
<style lang="css" scoped>
|
||||
.pass-container {
|
||||
width: 100%;
|
||||
max-width: 360px;
|
||||
vertical-align: top;
|
||||
display: inline-block;
|
||||
|
||||
border: solid 1px #949494;
|
||||
border-radius: 1em;
|
||||
padding: 1em;
|
||||
|
||||
overflow: hidden
|
||||
}
|
||||
|
||||
.pass-content {
|
||||
overflow: scroll;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1em;
|
||||
}
|
||||
</style>
|
||||
|
||||
<style lang="css">
|
||||
.pass-container {
|
||||
.fieldgroup-container {
|
||||
min-width: 0;
|
||||
display: flex;
|
||||
gap: 0.5em;
|
||||
min-height: 2lh;
|
||||
}
|
||||
.fieldgroup-label {
|
||||
font-weight: bold;
|
||||
font-size: 0.8em;
|
||||
}
|
||||
.fieldgroup-item {
|
||||
flex: 0 1 100%;
|
||||
min-width: 0;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
justify-content: center;
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.fieldgroup-item-image {
|
||||
max-height: 3lh;
|
||||
object-fit: contain;
|
||||
}
|
||||
.fieldgroup-item-qrcode {
|
||||
font-weight: bold;
|
||||
background-color: lightgray;
|
||||
width: 50%;
|
||||
aspect-ratio: 1;
|
||||
margin: auto;
|
||||
/* center content */
|
||||
display: flex;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
justify-content: center;
|
||||
padding: 1em;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
.nowrap {
|
||||
text-overflow: ellipsis;
|
||||
overflow: hidden;
|
||||
text-wrap: nowrap;
|
||||
}
|
||||
.bold {
|
||||
font-weight: bold;
|
||||
}
|
||||
.large {
|
||||
font-size: 1.5em;
|
||||
}
|
||||
.tight {
|
||||
line-height: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
config: PreviewFieldgroup;
|
||||
style_def: PlaceholderFieldGroupDefinition;
|
||||
}>();
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(v-for="{ label, content } of store.renderedFieldGroups[config.fieldgroup]" :label="label" :content="content" :content_type="style_def.content_type" :display_class="config.display" :display="style_def.display")
|
||||
</template>
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const props = defineProps<{
|
||||
style_def: PredefinedFieldGroupDefinition;
|
||||
config: PredefinedFieldgroupPreview;
|
||||
}>();
|
||||
|
||||
const isActive = computed(
|
||||
() =>
|
||||
store.layout.fieldgroups[props.style_def.identifier]?.active,
|
||||
);
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(v-if="isActive" :style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(v-for="{ label, content } of config.sample" :label="label" :content="content" content_type="text" display="with_label")
|
||||
</template>
|
||||
+29
@@ -0,0 +1,29 @@
|
||||
<script setup lang="ts">
|
||||
import FieldgroupItemPreview from './fieldgroup-item-preview.vue';
|
||||
import FieldgroupPreview from './fieldgroup-preview.vue';
|
||||
import SettingPreview from './setting-preview.vue';
|
||||
const props = defineProps<{
|
||||
config: PreviewRow;
|
||||
}>();
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.preview-row(v-if="'children' in config" :style="{ flexDirection: config.direction || 'row' }" :class="config.display")
|
||||
RowPreview(v-for="child of config.children" :config="child" )
|
||||
FieldgroupPreview(v-else-if="'fieldgroup' in config" :config="config")
|
||||
FieldgroupItemPreview(v-else-if="'value' in config" :label="config.label" :content="config.value" content_type="text" :display="!!config.label ? 'with_label' : 'plain'" :display_class="config.display")
|
||||
SettingPreview(v-else-if="'setting' in config" :config="config")
|
||||
|
||||
</template>
|
||||
<style lang="css" scoped>
|
||||
.preview-row {
|
||||
width: 100%;
|
||||
display: flex;
|
||||
gap: 1em;
|
||||
display: flex
|
||||
}
|
||||
.tight {
|
||||
gap: 0;
|
||||
}
|
||||
</style>
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive, toRaw, watchEffect } from "vue";
|
||||
import { StoreKey } from "../../walletStore";
|
||||
import FieldgroupItemPreview from "./fieldgroup-item-preview.vue";
|
||||
import PredefinedFieldgroupPreview from "./predefined-fieldgroup-preview.vue";
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
|
||||
const { config } = defineProps<{
|
||||
config: SettingPreview;
|
||||
}>();
|
||||
|
||||
const settingDef = computed(() => {
|
||||
return Object.fromEntries(
|
||||
store.styles[store.layout.style].settings.map(
|
||||
(x) => [x.identifier, x],
|
||||
),
|
||||
)[config.setting];
|
||||
});
|
||||
const settingValue = computed(() => {
|
||||
const val = store.settings[config.setting];
|
||||
if (settingDef.value.type == "image" && val && 'file' in val && val.file) {
|
||||
return URL.createObjectURL(val.file);
|
||||
} else {
|
||||
return val;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.fieldgroup-container(:style="{'flex-grow': config.relSize, 'flex-direction': config.direction || 'row'}")
|
||||
FieldgroupItemPreview(:content="settingValue" :content_type="settingDef.type" :display_class="config.display" display="plain")
|
||||
</template>
|
||||
@@ -0,0 +1,17 @@
|
||||
<script setup lang="ts">
|
||||
import Input from "./input/input.vue";
|
||||
import FileInput from "./input/file-input.vue";
|
||||
import { inject } from "vue";
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const props = defineProps<{
|
||||
field?: Setting;
|
||||
}>();
|
||||
const store = inject(StoreKey)!;
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
div.form-group
|
||||
Input(v-if='field.type == "text"' :label="field.label" :type="field.type" :required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)" :modelValue="store.settings[field.identifier]" :help_text="field.help_text")
|
||||
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required" :help_text="field.help_text" @change="(v) => store.setSetting(field.identifier, v)" :filename="store.settings[field.identifier]?.name" :current_url="store.settings[field.identifier]?.name")
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import { inject } from "vue";
|
||||
import PlaceholderFieldSettings from "./placeholder-field-settings.vue";
|
||||
import PredefinedFieldSettings from "./predefined-field-settings.vue";
|
||||
import SettingsField from "./settings-field.vue";
|
||||
import { StoreKey } from "../walletStore.js";
|
||||
const gettext = (window as any).gettext;
|
||||
|
||||
const store = inject(StoreKey)!;
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
h2.h3 {{ gettext("Settings") }}
|
||||
SettingsField(v-for="field of store.style.settings" :field="field" :key="field.identifier")
|
||||
h2.h3 {{ gettext("Field Groups") }}
|
||||
div(v-for="(fieldgroup, fieldgroupId) in store.style.fieldgroups" :id="'fieldgroup-' + fieldgroup.identifier")
|
||||
PlaceholderFieldSettings(
|
||||
v-if="fieldgroup.type == 'placeholder'"
|
||||
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup"
|
||||
:overflows="store.style.fieldgroups.slice(fieldgroupId + 1) \
|
||||
.filter(x => x.type == 'placeholder' && x.content_type === fieldgroup.content_type)"
|
||||
)
|
||||
PredefinedFieldSettings(v-else-if="fieldgroup.type == 'predefined'"
|
||||
v-model="store.layout.fieldgroups[fieldgroup.identifier]"
|
||||
:fieldgroup="fieldgroup")
|
||||
</template>
|
||||
@@ -0,0 +1,57 @@
|
||||
<script setup lang="ts">
|
||||
import { computed, inject, reactive } from 'vue'
|
||||
import Select from './input/select.vue'
|
||||
import I18nInput from './input/i18ninput.vue'
|
||||
import { StoreKey } from "../walletStore";
|
||||
|
||||
const store = inject(StoreKey)!
|
||||
const gettext = (window as any).gettext
|
||||
|
||||
const props = defineProps<{
|
||||
placeholderChoices: [string|null, string][];
|
||||
}>();
|
||||
|
||||
const entry = defineModel<FieldEntry>({ required: true })
|
||||
|
||||
const selection = computed({
|
||||
get() {
|
||||
if (entry.value.type === 'placeholder') {
|
||||
return entry.value.content
|
||||
} else if (entry.value.type === 'custom') {
|
||||
return "other"
|
||||
}
|
||||
},
|
||||
set(newValue) {
|
||||
if (newValue == "other") {
|
||||
entry.value.type = "custom"
|
||||
entry.value.content = {};
|
||||
} else {
|
||||
entry.value.type = "placeholder"
|
||||
entry.value.content = newValue
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const textContent = computed({
|
||||
get() {
|
||||
if (entry.value.type === 'placeholder') {
|
||||
return ""
|
||||
} else if (entry.value.type === 'custom') {
|
||||
return entry.value.content
|
||||
}
|
||||
},
|
||||
set(newValue) {
|
||||
entry.value.content = newValue
|
||||
}
|
||||
})
|
||||
|
||||
</script>
|
||||
|
||||
<template lang="pug">
|
||||
.i18n-form-group
|
||||
Select(
|
||||
v-model="selection"
|
||||
:choices="placeholderChoices"
|
||||
)
|
||||
I18nInput(v-model="textContent" v-if="selection === 'other'" :locales="store.locales")
|
||||
</template>
|
||||
@@ -0,0 +1,32 @@
|
||||
export function i18nstringLocalize(s: I18nString): string {
|
||||
if (typeof s === 'string') {
|
||||
return s
|
||||
}
|
||||
if (s === null) {
|
||||
return null
|
||||
}
|
||||
|
||||
var locale = document.body.attributes['data-pretixlocale'].value
|
||||
var short_locale = locale.split('-')[0]
|
||||
if (locale in s)
|
||||
return s[locale]
|
||||
|
||||
if (short_locale in s)
|
||||
return s[short_locale]
|
||||
|
||||
for (const k of Object.keys(s)) {
|
||||
if (k.split('-')[0] === short_locale && s[k]) {
|
||||
return s[k]
|
||||
}
|
||||
}
|
||||
|
||||
if (s['en'])
|
||||
return s['en']
|
||||
|
||||
for (const k of Object.keys(s)) {
|
||||
if (s[k]) {
|
||||
return s[k]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
type Platform = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
styles: Styles;
|
||||
};
|
||||
|
||||
type Style = {
|
||||
identifier: string;
|
||||
name: string;
|
||||
fieldgroups: FieldGroupDefinition[];
|
||||
settings: Setting[];
|
||||
};
|
||||
|
||||
type Variable = {
|
||||
label: string;
|
||||
sample: string;
|
||||
required_context: string[];
|
||||
};
|
||||
|
||||
type Styles = Record<string, Style>;
|
||||
type Variables = Record<string, Variable>;
|
||||
type VariableConfig = Record<string, Variables>;
|
||||
type Platforms = Platform[];
|
||||
|
||||
//
|
||||
|
||||
type BaseFieldGroupDefinition = {
|
||||
type: string;
|
||||
identifier: string;
|
||||
name: string;
|
||||
required: boolean;
|
||||
description: string;
|
||||
};
|
||||
|
||||
type FieldGroupDefinition =
|
||||
| PlaceholderFieldGroupDefinition
|
||||
| PredefinedFieldGroupDefinition;
|
||||
|
||||
type FieldGroupDisplay = "plain" | "with_label" | "code";
|
||||
|
||||
type PlaceholderFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: "placeholder";
|
||||
content_type: FieldContentType;
|
||||
default_entries: FieldEntry[];
|
||||
display: FieldGroupDisplay;
|
||||
min_entries: number | null;
|
||||
max_entries: number | null;
|
||||
context_args: string[];
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupDefinition = BaseFieldGroupDefinition & {
|
||||
type: "predefined";
|
||||
};
|
||||
|
||||
type I18nString = null | string | Record<string, string>;
|
||||
|
||||
type FieldContentType = "text" | "image";
|
||||
|
||||
type PlaceholderFieldEntry = {
|
||||
type: "placeholder";
|
||||
label?: I18nString;
|
||||
content?: string;
|
||||
};
|
||||
|
||||
type CustomFieldEntry = {
|
||||
type: "custom";
|
||||
label?: I18nString;
|
||||
content?: I18nString;
|
||||
};
|
||||
|
||||
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
|
||||
|
||||
type Setting = {
|
||||
identifier: string;
|
||||
label: string;
|
||||
type: "text" | "image";
|
||||
required: boolean;
|
||||
help_text: string;
|
||||
};
|
||||
|
||||
type PlaceholderFieldGroupConfig = {
|
||||
entries: Array<FieldEntry>;
|
||||
overflow: string | null;
|
||||
};
|
||||
|
||||
type PredefinedFieldGroupConfig = {
|
||||
active: boolean;
|
||||
};
|
||||
|
||||
type FieldGroupConfig =
|
||||
| PlaceholderFieldGroupConfig
|
||||
| PredefinedFieldGroupConfig;
|
||||
|
||||
type LayoutData = {
|
||||
fieldgroups?: Record<string, FieldGroupConfig>;
|
||||
settings?: Record<string, any>;
|
||||
};
|
||||
|
||||
type PlatformLayout = {
|
||||
platform: string;
|
||||
style: string | null;
|
||||
layout: LayoutData;
|
||||
};
|
||||
|
||||
type WalletLayout = {
|
||||
name?: string;
|
||||
platform_layouts: PlatformLayout[];
|
||||
};
|
||||
type WalletStore = {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: String;
|
||||
walletLayout: WalletLayout | null;
|
||||
};
|
||||
|
||||
type PreviewLayout = Array<PreviewRow>;
|
||||
type PreviewRow =
|
||||
| {
|
||||
children: Array<PreviewRow>;
|
||||
direction?: "row" | "column";
|
||||
display?: Array<string>;
|
||||
}
|
||||
| PreviewFieldgroup
|
||||
| FixedPreview
|
||||
| SettingPreview;
|
||||
|
||||
type PreviewProps = {
|
||||
relSize?: number;
|
||||
direction?: "row" | "column";
|
||||
display?: Array<string>;
|
||||
};
|
||||
type SettingPreview = {
|
||||
setting: string;
|
||||
} & PreviewProps;
|
||||
|
||||
type PreviewFieldgroup =
|
||||
| PredefinedFieldgroupPreview
|
||||
| PlaceholderFieldGroupPreview;
|
||||
type FixedPreview = { value: I18nString; label?: I18nString } & PreviewProps;
|
||||
|
||||
type PlaceholderFieldGroupPreview = {
|
||||
fieldgroup: string;
|
||||
} & PreviewProps;
|
||||
|
||||
type PredefinedFieldgroupPreview = {
|
||||
fieldgroup: string;
|
||||
sample: PreviewSample[];
|
||||
} & PreviewProps;
|
||||
|
||||
type PreviewSample = {
|
||||
content: I18nString;
|
||||
label: I18nString;
|
||||
};
|
||||
|
||||
type NewPlatformLayout = {
|
||||
style: string;
|
||||
fieldgroups: {};
|
||||
settings: {};
|
||||
file_settings: Record<string, WalletFile>;
|
||||
};
|
||||
type ServerSideFile = { url: string; name: string };
|
||||
type ClientSideFile = { file: File | null; identifier?: string };
|
||||
type WalletFile = ServerSideFile | ClientSideFile;
|
||||
@@ -0,0 +1 @@
|
||||
../../../../../../../pretix/static/pretixpresale/widget/src/lib/store.ts
|
||||
@@ -0,0 +1,28 @@
|
||||
import { createApp } from "vue";
|
||||
import App from "./components/app.vue";
|
||||
import { createWalletStore, StoreKey } from "./walletStore";
|
||||
const mountEl = document.querySelector<HTMLElement>("#editor")!;
|
||||
const store = createWalletStore({
|
||||
platforms: JSON.parse(
|
||||
document.querySelector("#platforms")?.textContent ?? "{}",
|
||||
),
|
||||
variables: JSON.parse(
|
||||
document.querySelector("#variables")?.textContent ?? "{}",
|
||||
),
|
||||
locales: JSON.parse(document.querySelector("#locales")?.textContent ?? "{}"),
|
||||
csrfToken: document.querySelector<HTMLInputElement>(
|
||||
"input[name=csrfmiddlewaretoken]",
|
||||
)?.value!!,
|
||||
layoutId: mountEl.dataset.layoutId!
|
||||
});
|
||||
store.load();
|
||||
const app = createApp(App);
|
||||
app.provide(StoreKey, store);
|
||||
app.mount(mountEl);
|
||||
|
||||
app.config.errorHandler = (error, _vm, info) => {
|
||||
// vue fatals on errors by default, which is a weird choice
|
||||
// https://github.com/vuejs/core/issues/3525
|
||||
// https://github.com/vuejs/router/discussions/2435
|
||||
console.error("[VUE]", info, error);
|
||||
};
|
||||
@@ -0,0 +1,321 @@
|
||||
import { i18nstringLocalize } from "./helpers.js";
|
||||
import { createStore } from "./lib/store";
|
||||
import { toRaw, type InjectionKey } from "vue";
|
||||
|
||||
export type WidgetStore = ReturnType<typeof createWalletStore>;
|
||||
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
|
||||
|
||||
function getDefaultFieldgroupState(
|
||||
fieldgroup: FieldGroupDefinition,
|
||||
): FieldGroupConfig {
|
||||
if (fieldgroup.type == "predefined") {
|
||||
return { active: fieldgroup.required };
|
||||
} else if (fieldgroup.type == "placeholder") {
|
||||
return {
|
||||
overflow: null,
|
||||
entries: JSON.parse(JSON.stringify(fieldgroup.default_entries)),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function parseExistingFieldgroupState(
|
||||
fieldgroup: FieldGroupDefinition,
|
||||
existing?: FieldGroupConfig,
|
||||
): FieldGroupConfig {
|
||||
if (!existing) {
|
||||
return getDefaultFieldgroupState(fieldgroup);
|
||||
}
|
||||
if (fieldgroup.type == "predefined") {
|
||||
return {
|
||||
active: "active" in existing ? existing.active : fieldgroup.required,
|
||||
};
|
||||
} else if (fieldgroup.type == "placeholder") {
|
||||
return {
|
||||
overflow: "overflow" in existing ? existing.overflow : null,
|
||||
// TODO: check that placeholders are possible
|
||||
entries: structuredClone(
|
||||
toRaw(
|
||||
"entries" in existing ? existing.entries : fieldgroup.default_entries,
|
||||
),
|
||||
),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export function createWalletStore(config: {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: string;
|
||||
layoutId: string;
|
||||
}) {
|
||||
return createStore({
|
||||
state: () => ({
|
||||
...config,
|
||||
loaded: false,
|
||||
activePlatform: config.platforms[0].identifier,
|
||||
name: null as string | null,
|
||||
platformLayouts: {} as Record<string, NewPlatformLayout>,
|
||||
}),
|
||||
getters: {
|
||||
platform(): Platform {
|
||||
return this.getPlatform(this.activePlatform);
|
||||
},
|
||||
layout() {
|
||||
if (!this.loaded) {
|
||||
return;
|
||||
}
|
||||
return this.platformLayouts[this.activePlatform] || null;
|
||||
},
|
||||
style(): Style {
|
||||
if (this.layout) {
|
||||
return this.platform.styles[this.layout.style];
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
styles() {
|
||||
return this.platform.styles;
|
||||
},
|
||||
settings() {
|
||||
const settings = {};
|
||||
for (const setting of this.style.settings) {
|
||||
if (setting.type == "text") {
|
||||
settings[setting.identifier] =
|
||||
this.layout.settings[setting.identifier];
|
||||
} else if (setting.type == "image") {
|
||||
settings[setting.identifier] =
|
||||
this.layout.file_settings[setting.identifier];
|
||||
}
|
||||
}
|
||||
return settings;
|
||||
},
|
||||
renderedFieldGroups() {
|
||||
const content = {};
|
||||
const group_defs = this.style.fieldgroups;
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
content[fieldgroup.identifier] = [];
|
||||
const layout_group = this.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
] as any as PlaceholderFieldGroupConfig;
|
||||
for (const entry of layout_group.entries) {
|
||||
const placeholder =
|
||||
entry.type === "placeholder"
|
||||
? this.variables[fieldgroup.content_type][entry.content]
|
||||
: null;
|
||||
|
||||
let label = i18nstringLocalize(entry.label);
|
||||
if (placeholder && !label) {
|
||||
label = i18nstringLocalize(placeholder.label);
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (entry.type == "custom") {
|
||||
value = i18nstringLocalize(entry.content);
|
||||
} else if (entry.type == "placeholder") {
|
||||
value =
|
||||
placeholder?.sample ||
|
||||
`(unknown placeholder: ${entry.content})`;
|
||||
}
|
||||
content[fieldgroup.identifier].push({
|
||||
entry,
|
||||
label,
|
||||
content: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
const layout_group: PlaceholderFieldGroupConfig =
|
||||
this.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
];
|
||||
if (
|
||||
fieldgroup.max_entries &&
|
||||
content[fieldgroup.identifier].length > fieldgroup.max_entries
|
||||
) {
|
||||
const overflow = content[fieldgroup.identifier].slice(
|
||||
fieldgroup.max_entries,
|
||||
);
|
||||
content[fieldgroup.identifier] = content[
|
||||
fieldgroup.identifier
|
||||
].slice(0, fieldgroup.max_entries);
|
||||
if (layout_group.overflow) {
|
||||
content[layout_group.overflow].splice(0, 0, ...overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
getPlatform(identifier: string): Platform {
|
||||
for (const platform of this.platforms) {
|
||||
if (platform.identifier === identifier) {
|
||||
return platform;
|
||||
}
|
||||
}
|
||||
},
|
||||
load() {
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
this.parseServerLayout(x);
|
||||
this.loaded = true;
|
||||
})
|
||||
.catch(alert);
|
||||
},
|
||||
parseServerLayout(serverLayout) {
|
||||
this.name = serverLayout.name;
|
||||
for (const layout of serverLayout.platform_layouts) {
|
||||
const clientLayout = {
|
||||
style: layout.style,
|
||||
fieldgroups: {},
|
||||
settings: layout.layout.settings,
|
||||
file_settings: layout.file_settings,
|
||||
};
|
||||
const styleDefinition = this.getPlatform(layout.platform).styles[
|
||||
layout.style
|
||||
];
|
||||
console.log(styleDefinition);
|
||||
for (const fieldgroup of styleDefinition.fieldgroups) {
|
||||
clientLayout.fieldgroups[fieldgroup.identifier] =
|
||||
parseExistingFieldgroupState(
|
||||
fieldgroup,
|
||||
layout.layout.fieldgroups[fieldgroup.identifier],
|
||||
);
|
||||
}
|
||||
this.platformLayouts[layout.platform] = clientLayout;
|
||||
}
|
||||
},
|
||||
setPlatform(platform: string) {
|
||||
this.activePlatform = platform;
|
||||
},
|
||||
setStyle(style: string | null) {
|
||||
if (style === null) {
|
||||
delete this.platformLayouts[this.activePlatform];
|
||||
} else if (Object.keys(this.platform.styles).includes(style)) {
|
||||
const newLayout = {
|
||||
style,
|
||||
fieldgroups: {},
|
||||
settings: {},
|
||||
file_settings: {},
|
||||
};
|
||||
for (const fieldgroup of this.platform.styles[style].fieldgroups) {
|
||||
newLayout.fieldgroups[fieldgroup.identifier] =
|
||||
getDefaultFieldgroupState(fieldgroup);
|
||||
}
|
||||
this.platformLayouts[this.activePlatform] = newLayout;
|
||||
// TODO: keep old fieldgroups & settings if matching
|
||||
}
|
||||
},
|
||||
getSetting(identifier: string): Setting {
|
||||
for (const setting of this.style.settings) {
|
||||
if (setting.identifier === identifier) {
|
||||
return setting;
|
||||
}
|
||||
}
|
||||
},
|
||||
setSetting(identifier: string, value: string | File) {
|
||||
const setting = this.getSetting(identifier);
|
||||
if (!setting) return;
|
||||
|
||||
if (
|
||||
setting.type === "image" &&
|
||||
(value instanceof File || value === null)
|
||||
) {
|
||||
this.layout.file_settings[setting.identifier] = {
|
||||
file: value as File | null,
|
||||
};
|
||||
} else if (setting.type == "text" && typeof value == "string") {
|
||||
this.layout.settings[setting.identifier] = value;
|
||||
}
|
||||
},
|
||||
async uploadFile(file: ClientSideFile) {
|
||||
return await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
body: file.file,
|
||||
headers: {
|
||||
"content-disposition": `attachment; filename="${encodeURI(file.file.name)}"`,
|
||||
"content-type": file.file.type || "application/octet-stream",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
file.identifier = x.id;
|
||||
return x.id;
|
||||
})
|
||||
.catch(alert);
|
||||
},
|
||||
async serializePlatformLayout(platform, layout: NewPlatformLayout) {
|
||||
const uploadedFiles = Object.fromEntries(
|
||||
(
|
||||
await Promise.all(
|
||||
Object.entries(layout.file_settings).map(async ([k, v]) => {
|
||||
if ("url" in v) {
|
||||
return;
|
||||
} else if ("identifier" in v) {
|
||||
return [k, v.identifier];
|
||||
} else if ("file" in v && v.file instanceof File) {
|
||||
return [k, await this.uploadFile(v)];
|
||||
} else if ("file" in v && v.file == null) {
|
||||
return [k, null];
|
||||
}
|
||||
}),
|
||||
)
|
||||
).filter((x) => !!x),
|
||||
);
|
||||
return {
|
||||
platform,
|
||||
style: layout.style,
|
||||
file_settings: uploadedFiles,
|
||||
layout: {
|
||||
fieldgroups: layout.fieldgroups,
|
||||
settings: layout.settings,
|
||||
},
|
||||
};
|
||||
},
|
||||
async serializeCurrentPlatformLayout() {
|
||||
return this.serializePlatformLayout(this.activePlatform, this.layout)
|
||||
},
|
||||
async serializeLayout() {
|
||||
const layoutPromises = Object.entries(this.platformLayouts).map(
|
||||
([platform, layout]) =>
|
||||
this.serializePlatformLayout(platform, layout),
|
||||
);
|
||||
return {
|
||||
name: this.name,
|
||||
platform_layouts: await Promise.all(layoutPromises),
|
||||
};
|
||||
},
|
||||
async save() {
|
||||
const serializedLayout = await this.serializeLayout();
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(serializedLayout),
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((x) => alert(x))
|
||||
.then((x) => {
|
||||
this.parseServerLayout(x);
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,252 @@
|
||||
import { i18nstringLocalize } from "./helpers.js";
|
||||
import { createStore } from "./lib/store.ts";
|
||||
import { toRaw, type InjectionKey } from "vue";
|
||||
|
||||
export type WidgetStore = ReturnType<typeof createWalletStore>;
|
||||
export const StoreKey: InjectionKey<WidgetStore> = Symbol("WidgetStore");
|
||||
|
||||
export function createWalletStore(config: {
|
||||
platforms: Platforms;
|
||||
variables: VariableConfig;
|
||||
locales: Record<string, string>;
|
||||
csrfToken: string;
|
||||
layoutId: string;
|
||||
}) {
|
||||
return createStore({
|
||||
state: () => ({
|
||||
...config,
|
||||
walletLayout: null as WalletLayout | null,
|
||||
currentPlatform: config.platforms[0].identifier,
|
||||
loaded: false,
|
||||
files: {} as Record<string, File>
|
||||
}),
|
||||
getters: {
|
||||
currentPlatformStyles() {
|
||||
for (const platform of this.platforms) {
|
||||
if (platform.identifier === this.currentPlatform) {
|
||||
return platform.styles;
|
||||
}
|
||||
}
|
||||
throw "Unknown platform";
|
||||
},
|
||||
currentPlatformLayout(): PlatformLayout {
|
||||
if (!this.walletLayout) {
|
||||
throw "currentPlatformLayout access before store was loaded";
|
||||
}
|
||||
for (const layout of this.walletLayout.platform_layouts) {
|
||||
if (layout.platform === this.currentPlatform) {
|
||||
if (!("fieldgroups" in layout.layout)) {
|
||||
layout.layout.fieldgroups = {};
|
||||
}
|
||||
if (!("settings" in layout.layout)) {
|
||||
layout.layout.settings = {};
|
||||
}
|
||||
return layout;
|
||||
}
|
||||
}
|
||||
const newLayout = {
|
||||
platform: this.currentPlatform,
|
||||
style: null,
|
||||
layout: { fieldgroups: {}, settings: {} },
|
||||
};
|
||||
this.walletLayout.platform_layouts.push(newLayout);
|
||||
return newLayout;
|
||||
},
|
||||
|
||||
currentLayoutFieldContent() {
|
||||
const content = {};
|
||||
const group_defs =
|
||||
this.currentPlatformStyles[this.currentPlatformLayout.style]
|
||||
.fieldgroups;
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
content[fieldgroup.identifier] = [];
|
||||
const layout_group = this.currentPlatformLayout.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
] as any as PlaceholderFieldGroupConfig;
|
||||
for (const entry of layout_group.entries) {
|
||||
const placeholder =
|
||||
entry.type === "placeholder"
|
||||
? this.variables[fieldgroup.content_type][entry.content]
|
||||
: null;
|
||||
|
||||
let label = i18nstringLocalize(entry.label);
|
||||
if (placeholder && !label) {
|
||||
label = i18nstringLocalize(placeholder.label);
|
||||
}
|
||||
|
||||
let value = null;
|
||||
if (entry.type == "custom") {
|
||||
value = i18nstringLocalize(entry.content);
|
||||
} else if (entry.type == "placeholder") {
|
||||
value =
|
||||
placeholder?.sample ||
|
||||
`(unknown placeholder: ${entry.content})`;
|
||||
}
|
||||
content[fieldgroup.identifier].push({
|
||||
entry,
|
||||
label,
|
||||
content: value,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const fieldgroup of group_defs) {
|
||||
if (fieldgroup.type == "placeholder") {
|
||||
const layout_group: PlaceholderFieldGroupConfig =
|
||||
this.currentPlatformLayout.layout.fieldgroups[
|
||||
fieldgroup.identifier
|
||||
];
|
||||
if (
|
||||
fieldgroup.max_entries &&
|
||||
content[fieldgroup.identifier].length > fieldgroup.max_entries
|
||||
) {
|
||||
const overflow = content[fieldgroup.identifier].slice(
|
||||
fieldgroup.max_entries,
|
||||
);
|
||||
content[fieldgroup.identifier] = content[
|
||||
fieldgroup.identifier
|
||||
].slice(0, fieldgroup.max_entries);
|
||||
if (layout_group.overflow) {
|
||||
content[layout_group.overflow].splice(0, 0, ...overflow);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return content;
|
||||
},
|
||||
currentLayoutSettings() {
|
||||
return {...this.currentPlatformLayout.file_settings, ...this.currentPlatformLayout.layout.settings}
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
load() {
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.then((x) => {
|
||||
this.walletLayout = x;
|
||||
this.loaded = true;
|
||||
});
|
||||
},
|
||||
async uploadFile(file: File) {
|
||||
return await fetch("/api/v1/upload", {
|
||||
method: "POST",
|
||||
body: file,
|
||||
headers: {
|
||||
"content-disposition": `attachment; filename="${encodeURI(file.name)}"`,
|
||||
"content-type": file.type || "application/octet-stream",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
})
|
||||
.then((x) => x.json())
|
||||
.then((x) => x.id);
|
||||
},
|
||||
async saveLayout() {
|
||||
const layoutToSave = structuredClone(toRaw(this.walletLayout))
|
||||
// TODO: error handling, parallelization
|
||||
for (const platformLayout of layoutToSave.platform_layouts) {
|
||||
const platformStyle = this.platforms.filter(
|
||||
(x) => x.identifier == platformLayout.platform,
|
||||
)[0].styles[platformLayout.style];
|
||||
for (const setting of platformStyle.settings) {
|
||||
console.log(setting, platformLayout.layout?.settings[setting.identifier])
|
||||
if (
|
||||
setting.type === "image" &&
|
||||
platformLayout.layout?.settings[setting.identifier] instanceof
|
||||
File
|
||||
) {
|
||||
platformLayout.layout.settings[setting.identifier] = await
|
||||
this.uploadFile(
|
||||
platformLayout.layout.settings[setting.identifier],
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
// TODO: error handling / proper api client
|
||||
fetch(
|
||||
`/api/v1/organizers/demo/events/wallet/walletlayouts/${this.layoutId}/`,
|
||||
{
|
||||
method: "PUT",
|
||||
headers: {
|
||||
"content-type": "application/json",
|
||||
"X-CSRFToken": this.csrfToken,
|
||||
},
|
||||
body: JSON.stringify(layoutToSave),
|
||||
},
|
||||
)
|
||||
.then((x) => x.json())
|
||||
.catch((x) => alert(x))
|
||||
.then((x) => {
|
||||
this.walletLayout = x;
|
||||
});
|
||||
},
|
||||
setCurrentPlatformStyle(style: string | null) {
|
||||
if (style === null) {
|
||||
this.currentPlatformLayout.style = null;
|
||||
this.currentPlatformLayout.layout.fieldgroups = {};
|
||||
} else if (Object.keys(this.currentPlatformStyles).includes(style)) {
|
||||
const oldStyle =
|
||||
this.currentPlatformLayout.style !== null
|
||||
? this.currentPlatformStyles[this.currentPlatformLayout.style]
|
||||
: { fieldgroups: [] };
|
||||
const newStyle = this.currentPlatformStyles[style];
|
||||
|
||||
const oldFieldGroups = Object.fromEntries(
|
||||
oldStyle.fieldgroups.map((x) => [x.identifier, x]),
|
||||
);
|
||||
const newFieldGroups = Object.fromEntries(
|
||||
newStyle.fieldgroups.map((x) => [x.identifier, x]),
|
||||
);
|
||||
const keysToKeep = new Set(
|
||||
Object.keys(this.currentPlatformLayout.layout.fieldgroups).filter(
|
||||
(x) =>
|
||||
oldFieldGroups[x]?.type === "placeholder" &&
|
||||
newFieldGroups[x]?.type === "placeholder" &&
|
||||
oldFieldGroups[x]?.content_type ===
|
||||
newFieldGroups[x]?.content_type,
|
||||
),
|
||||
);
|
||||
const keysToDefault = new Set(Object.keys(newFieldGroups)).difference(
|
||||
keysToKeep,
|
||||
);
|
||||
const keysToRemove = new Set(
|
||||
Object.keys(this.currentPlatformLayout.layout.fieldgroups),
|
||||
)
|
||||
.difference(keysToKeep)
|
||||
.difference(keysToDefault);
|
||||
|
||||
for (const key of keysToRemove) {
|
||||
delete this.currentPlatformLayout.layout.fieldgroups[key];
|
||||
}
|
||||
|
||||
for (const key of keysToDefault) {
|
||||
if (newFieldGroups[key].type == "placeholder") {
|
||||
this.currentPlatformLayout.layout.fieldgroups[key] = {
|
||||
overflow: null,
|
||||
entries: JSON.parse(
|
||||
JSON.stringify(newFieldGroups[key].default_entries),
|
||||
),
|
||||
active:
|
||||
newFieldGroups[key].required ||
|
||||
newFieldGroups[key].default_entries.length > 0,
|
||||
};
|
||||
} else {
|
||||
this.currentPlatformLayout.layout.fieldgroups[key] = {
|
||||
active: newFieldGroups[key].required,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.currentPlatformLayout.style = style;
|
||||
}
|
||||
},
|
||||
setSetting(identifier: string, value: string | File | null) {
|
||||
this.currentPlatformLayout.layout.settings[identifier] = value;
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
from .apple import ApplePlatform, AppleWalletEventTicket
|
||||
from .google import GooglePlatform, GoogleWalletEventTicket
|
||||
from pretix.plugins.wallet.styles.base import PassStyle
|
||||
|
||||
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
|
||||
|
||||
AVAILABLE_STYLES: dict[str, list[type[PassStyle]]] = {
|
||||
"apple": [AppleWalletEventTicket],
|
||||
"google": [GoogleWalletEventTicket],
|
||||
}
|
||||
|
||||
AVAILABLE_STYLES_DICT = {
|
||||
plat: {s.identifier: s for s in styls} for plat, styls in AVAILABLE_STYLES.items()
|
||||
}
|
||||
|
||||
|
||||
def get_style(platform: str, identifier: str) -> type[PassStyle] | None:
|
||||
return AVAILABLE_STYLES_DICT.get(platform, {}).get(identifier)
|
||||
|
||||
|
||||
__all__ = ["AVAILABLE_PLATFORMS", "AVAILABLE_STYLES", "PassStyle"]
|
||||
@@ -0,0 +1,405 @@
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
from pretix.plugins.wallet.styles.base import (
|
||||
FieldEntryType,
|
||||
FieldGroupDisplay,
|
||||
ImageFieldGroup,
|
||||
PlaceholderFieldGroup,
|
||||
PredefinedFieldGroup,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
SettingsField,
|
||||
)
|
||||
from django.utils.translation import gettext as _, gettext_lazy, override
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import io
|
||||
import hashlib
|
||||
import zipfile
|
||||
import cryptography
|
||||
import cryptography.x509
|
||||
import cryptography.hazmat.primitives.serialization.pkcs7
|
||||
import cryptography.hazmat.primitives.hashes
|
||||
import json
|
||||
from django.contrib.staticfiles import finders
|
||||
from pretix.base.models import OrderPosition
|
||||
from django.utils.encoding import force_bytes
|
||||
import tempfile
|
||||
from django.conf import settings
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
import logging
|
||||
from django.forms import ValidationError
|
||||
|
||||
logger = logging.getLogger()
|
||||
|
||||
class ApplePlatform(WalletPlatform):
|
||||
identifier = "apple"
|
||||
name = _("Apple")
|
||||
|
||||
|
||||
class FormattedLazyI18nString:
|
||||
def __init__(self, base_str: LazyI18nString, **format_args: str):
|
||||
self.base_str = base_str
|
||||
self.format_args = format_args
|
||||
|
||||
def localize(self, language):
|
||||
return self.base_str.localize(language).format(**self.format_args)
|
||||
|
||||
|
||||
def lazyi18nstring_from_gettext(text: str, locales: set[str]) -> LazyI18nString:
|
||||
data = {}
|
||||
for locale in locales:
|
||||
with override(locale):
|
||||
data[locale] = _(text)
|
||||
return LazyI18nString(data)
|
||||
|
||||
|
||||
class StringResource:
|
||||
entries: dict[str, LazyI18nString | FormattedLazyI18nString]
|
||||
locales: set[str]
|
||||
|
||||
def __init__(self, locales):
|
||||
self.entries = {}
|
||||
self.locales = set(locales)
|
||||
|
||||
def add_entry(self, key: str, value: LazyI18nString | FormattedLazyI18nString):
|
||||
if key in self.entries:
|
||||
raise ValueError(f"{key} already exists in this StringResource")
|
||||
self.entries[key] = value
|
||||
|
||||
def escape(self, string):
|
||||
return string.translate(
|
||||
str.maketrans({'"': '\\"', "\r": "\\r", "\n": "\\n", "\\": "\\\\"})
|
||||
)
|
||||
|
||||
def generate_resource(self, language):
|
||||
output = ""
|
||||
for key, entry in self.entries.items():
|
||||
output += (
|
||||
f'"{self.escape(key)}" = "{self.escape(entry.localize(language))}";\n'
|
||||
)
|
||||
return output.strip()
|
||||
|
||||
def generate(self):
|
||||
return {language: self.generate_resource(language) for language in self.locales}
|
||||
|
||||
|
||||
class SignedZipFile:
|
||||
"""Generates a zip-file with manifest and signature as apple expects a pkpass file to be"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
ca_certificate: str | bytes,
|
||||
certificate: str | bytes,
|
||||
key: str | bytes,
|
||||
password,
|
||||
):
|
||||
self.ca_certificate = cryptography.x509.load_pem_x509_certificate(
|
||||
force_bytes(ca_certificate)
|
||||
)
|
||||
self.certificate = cryptography.x509.load_pem_x509_certificate(
|
||||
force_bytes(certificate)
|
||||
)
|
||||
self.key = cryptography.hazmat.primitives.serialization.load_pem_private_key(
|
||||
force_bytes(key), force_bytes(password) if password else None
|
||||
)
|
||||
self.password = password
|
||||
|
||||
self.file = io.BytesIO()
|
||||
self.zip_file = zipfile.ZipFile(self.file, "w")
|
||||
self.manifest = {}
|
||||
|
||||
def sign(self, data: bytes):
|
||||
return (
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7SignatureBuilder()
|
||||
.set_data(data)
|
||||
.add_signer(
|
||||
self.certificate,
|
||||
self.key,
|
||||
cryptography.hazmat.primitives.hashes.SHA256(),
|
||||
)
|
||||
.add_certificate(self.ca_certificate)
|
||||
.sign(
|
||||
cryptography.hazmat.primitives.serialization.Encoding.DER,
|
||||
[
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.Binary,
|
||||
cryptography.hazmat.primitives.serialization.pkcs7.PKCS7Options.DetachedSignature,
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
def finish(self):
|
||||
manifest = json.dumps(self.manifest).encode()
|
||||
signature = self.sign(manifest)
|
||||
self.add_file("manifest.json", manifest)
|
||||
self.add_file("signature", signature)
|
||||
self.zip_file.close()
|
||||
return self.file.getvalue()
|
||||
|
||||
def add_file(self, filename: str, content: str | bytes):
|
||||
if isinstance(content, str):
|
||||
content = content.encode()
|
||||
|
||||
with self.zip_file.open(filename, "w") as f:
|
||||
f.write(content)
|
||||
self.manifest[filename] = hashlib.sha1(content).hexdigest()
|
||||
|
||||
def convert_to_png(file, max_size=None):
|
||||
# TODO: move validation to upload
|
||||
try:
|
||||
from PIL import Image
|
||||
except ImportError:
|
||||
return file
|
||||
|
||||
file.open("rb")
|
||||
file.seek(0)
|
||||
try:
|
||||
with (
|
||||
Image.open(file, formats=settings.PILLOW_FORMATS_IMAGE) as im,
|
||||
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
|
||||
):
|
||||
if max_size:
|
||||
im.thumbnail(max_size)
|
||||
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.")
|
||||
)
|
||||
|
||||
class AppleWalletStyle(PassStyle):
|
||||
@property
|
||||
def settings(self):
|
||||
return [
|
||||
SettingsField(
|
||||
identifier="logo",
|
||||
label=_("Logo"),
|
||||
type="image",
|
||||
required=False,
|
||||
help_text="Will be displayed on the top left corner of the pass",
|
||||
),
|
||||
SettingsField(
|
||||
identifier="icon",
|
||||
label=_("Icon"),
|
||||
type="image",
|
||||
required=False,
|
||||
help_text="Will be displayed as the file icon",
|
||||
),
|
||||
]
|
||||
|
||||
def pass_content(self, fields, strings):
|
||||
raise NotImplementedError()
|
||||
|
||||
def generate_pass_json(self, fields, op, strings):
|
||||
ticket = str(op.item.name)
|
||||
if op.variation:
|
||||
ticket += " - " + str(op.variation)
|
||||
|
||||
description = FormattedLazyI18nString(
|
||||
LazyI18nString.from_gettext("Ticket for {event} ({product})"),
|
||||
event=self.event.name,
|
||||
product=ticket,
|
||||
)
|
||||
strings.add_entry("description", description)
|
||||
|
||||
serialNumber = "%s-%s-%s-%d" % (
|
||||
self.event.organizer.slug,
|
||||
self.event.slug,
|
||||
op.order.code,
|
||||
op.pk,
|
||||
)
|
||||
|
||||
pass_json = {
|
||||
"formatVersion": 1,
|
||||
"description": "description",
|
||||
"organizationName": self.event.organizer.name,
|
||||
"passTypeIdentifier": self.event.settings.wallet_apple_pass_type_id,
|
||||
"teamIdentifier": self.event.settings.wallet_apple_team_id,
|
||||
"serialNumber": serialNumber,
|
||||
**self.pass_content(fields, strings),
|
||||
}
|
||||
return pass_json
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
order = op.order
|
||||
filename = "{}-{}.pkpass".format(order.event.slug, order.code)
|
||||
|
||||
fields = self.get_pass_fields(op)
|
||||
|
||||
pkpass = SignedZipFile(
|
||||
self.event.settings.wallet_apple_ca_certificate.read(),
|
||||
self.event.settings.wallet_apple_certificate.read(),
|
||||
self.event.settings.wallet_apple_key.read(),
|
||||
self.event.settings.wallet_apple_key_password,
|
||||
)
|
||||
strings = StringResource(locales=self.event.settings.locales)
|
||||
|
||||
pass_json = self.generate_pass_json(fields, op, strings)
|
||||
print(pass_json)
|
||||
if (file := self.file_settings.get("logo")):
|
||||
logo = convert_to_png(file, (480, 150)) # TODO: check max_size against apple HIG
|
||||
else:
|
||||
# TODO: move to own plugin folder
|
||||
logo = open(finders.find("pretix_passbook/logo.png"), "rb")
|
||||
|
||||
if (file := self.file_settings.get("icon")):
|
||||
icon = convert_to_png(file, max_size=(87,87)) # TODO: check max_size against apple HIG
|
||||
else:
|
||||
icon = open(finders.find("pretix_passbook/icon.png"), "rb")
|
||||
|
||||
pkpass.add_file("icon.png", icon.read())
|
||||
pkpass.add_file("logo.png", logo.read())
|
||||
|
||||
for lang, content in strings.generate().items():
|
||||
pkpass.add_file(f"{lang}.lproj/pass.strings", content)
|
||||
pkpass.add_file("pass.json", json.dumps(pass_json))
|
||||
result = pkpass.finish()
|
||||
return filename, "application/vnd.apple.pkpass", result
|
||||
|
||||
|
||||
class AppleWalletEventTicket(AppleWalletStyle):
|
||||
identifier = "event_1"
|
||||
name = _("Event Ticket Layout 1")
|
||||
fieldgroups = [
|
||||
TextFieldGroup(
|
||||
identifier="logo_text",
|
||||
name=_("Logo text"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
default_entries=[],
|
||||
context_args={"order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="primary",
|
||||
name=_("Primary"),
|
||||
min_entries=1,
|
||||
max_entries=1,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
label=LazyI18nString({"de": "Tickettyp", "en": "Ticket type"}),
|
||||
content="item",
|
||||
)
|
||||
], # TODO: support Lazyi18nproxy here by using lazyi18nstring_from_gettext
|
||||
description=_("These fields appear prominently featured on the pass."),
|
||||
required=True,
|
||||
context_args={"order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="secondary",
|
||||
name=_("Secondary"),
|
||||
max_entries=4,
|
||||
context_args={"order_position"},
|
||||
), # 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="header",
|
||||
name=_("Header"),
|
||||
max_entries=3,
|
||||
context_args={"order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="auxiliary",
|
||||
name=_("Auxiliary"),
|
||||
max_entries=4,
|
||||
context_args={"order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
name=_("QR-Code"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.CODE,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="secret",
|
||||
)
|
||||
],
|
||||
context_args={"order_position"},
|
||||
),
|
||||
TextFieldGroup(
|
||||
identifier="back",
|
||||
name=_("Back"),
|
||||
context_args={"order_position"},
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def preview_layout(self):
|
||||
return [
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{"setting": "logo"},
|
||||
{
|
||||
"fieldgroup": "logo_text",
|
||||
"relSize": 3,
|
||||
"display": ["bold", "large", "centered"],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "header",
|
||||
"relSize": 2,
|
||||
"display": ["large", "tight"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{"fieldgroup": "primary", "display": "large"},
|
||||
{"fieldgroup": "secondary"},
|
||||
{"fieldgroup": "auxiliary"},
|
||||
{"fieldgroup": "code"},
|
||||
],
|
||||
[{"fieldgroup": "back", "direction": "column"}],
|
||||
]
|
||||
|
||||
def convert_fields(self, strings, fields, prefix):
|
||||
converted = []
|
||||
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 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):
|
||||
content: dict[str, Any] = {
|
||||
"eventTicket": {
|
||||
"primaryFields": self.convert_fields(
|
||||
strings, fields["primary"], "primary"
|
||||
),
|
||||
"secondaryFields": self.convert_fields(
|
||||
strings, fields["secondary"], "secondary"
|
||||
),
|
||||
"auxiliaryFields": self.convert_fields(
|
||||
strings, fields["auxiliary"], "auxiliary"
|
||||
),
|
||||
"backFields": self.convert_fields(strings, fields["back"], "back"),
|
||||
"headerFields": self.convert_fields(
|
||||
strings, fields["header"], "header"
|
||||
),
|
||||
},
|
||||
}
|
||||
if fields["logo_text"]:
|
||||
content["logoText"] = self.convert_fields(
|
||||
strings, fields["logo_text"], "logo_text"
|
||||
)[0]["value"]
|
||||
|
||||
if fields["code"]:
|
||||
content["barcodes"] = [
|
||||
{
|
||||
"format": "PKBarcodeFormatQR",
|
||||
"message": str(fields["code"][0]["value"]),
|
||||
"messageEncoding": "utf-8",
|
||||
"altText": str(fields["code"][0]["value"]),
|
||||
}
|
||||
]
|
||||
return content
|
||||
@@ -0,0 +1,460 @@
|
||||
import enum
|
||||
from typing import Literal, OrderedDict, TypedDict
|
||||
from i18nfield.strings import LazyI18nString
|
||||
import jsonschema
|
||||
from django.core.exceptions import ValidationError
|
||||
from pretix.base.models import OrderPosition
|
||||
from ..placeholders import WalletPlaceholderRenderer, get_available_context, get_wallet_placeholder_renderer, get_wallet_placeholders
|
||||
from django import forms
|
||||
from pretix.api.helpers import handle_file_upload
|
||||
from django.core.files import File
|
||||
|
||||
class WalletPlatform:
|
||||
identifier: str
|
||||
name: str
|
||||
|
||||
|
||||
class LayoutContext(TypedDict):
|
||||
placeholders: dict[str, dict]
|
||||
placeholder_renderer: WalletPlaceholderRenderer
|
||||
|
||||
|
||||
class FieldGroupType(enum.Enum):
|
||||
PLACEHOLDER = "placeholder"
|
||||
PREDEFINED = "predefined"
|
||||
|
||||
|
||||
class FieldGroupDisplay(enum.Enum):
|
||||
PLAIN = "plain"
|
||||
WITH_LABEL = "with_label"
|
||||
CODE = "code"
|
||||
|
||||
|
||||
class FieldGroup:
|
||||
type: FieldGroupType
|
||||
identifier: str
|
||||
name: str
|
||||
description: str
|
||||
required: bool = False
|
||||
|
||||
def __init__(self, identifier: str, name: str, description=None, required=False):
|
||||
self.identifier = identifier
|
||||
self.name = name
|
||||
self.required = required
|
||||
self.description = description or ""
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
) -> dict:
|
||||
raise NotImplementedError()
|
||||
|
||||
def asdict(self, context: LayoutContext):
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"description": self.description,
|
||||
"required": self.required,
|
||||
}
|
||||
|
||||
|
||||
class FieldContentType(enum.Enum):
|
||||
IMAGE = "image"
|
||||
TEXT = "text"
|
||||
|
||||
|
||||
class FieldEntryType(enum.Enum):
|
||||
CUSTOM = "custom"
|
||||
PLACEHOLDER = "placeholder"
|
||||
|
||||
|
||||
class FieldEntry[T]:
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: T
|
||||
|
||||
def __init__(
|
||||
self, type: FieldEntryType, content: T, label: LazyI18nString | None = None
|
||||
):
|
||||
self.type = type
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"content": self.content,
|
||||
"label": self.label.data if self.label else None,
|
||||
}
|
||||
|
||||
|
||||
class PlaceholderFieldEntry(FieldEntry[str]):
|
||||
type = FieldEntryType.PLACEHOLDER
|
||||
label: LazyI18nString | None
|
||||
content: str
|
||||
|
||||
def __init__(self, content: str, label: LazyI18nString | None = None):
|
||||
self.label = label
|
||||
self.content = content
|
||||
|
||||
|
||||
class CustomFieldEntry(FieldEntry[LazyI18nString]):
|
||||
type: FieldEntryType
|
||||
label: LazyI18nString | None
|
||||
content: LazyI18nString
|
||||
|
||||
def asdict(self) -> dict:
|
||||
return {
|
||||
"type": self.type.value,
|
||||
"content": self.content.data,
|
||||
"label": self.label.data if self.label else None,
|
||||
}
|
||||
|
||||
|
||||
class PredefinedFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PREDEFINED
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
):
|
||||
return {"type": "object", "properties": {"active": {"type": "boolean"}}}
|
||||
|
||||
|
||||
class PlaceholderFieldGroup(FieldGroup):
|
||||
type = FieldGroupType.PLACEHOLDER
|
||||
content_type: FieldContentType
|
||||
default_entries: list[FieldEntry]
|
||||
display: FieldGroupDisplay
|
||||
min_entries: int | None
|
||||
max_entries: int | None
|
||||
context_args: set[
|
||||
str
|
||||
] # what context arguments are available when rendering this fieldgroup
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
name: str,
|
||||
content_type: FieldContentType,
|
||||
description: str = "",
|
||||
required=False,
|
||||
default_entries=None,
|
||||
min_entries=None,
|
||||
max_entries=None,
|
||||
display=FieldGroupDisplay.WITH_LABEL,
|
||||
context_args: set[str] | None = None,
|
||||
):
|
||||
super().__init__(identifier, name, description, required)
|
||||
self.content_type = content_type
|
||||
self.default_entries = default_entries or []
|
||||
self.min_entries = min_entries
|
||||
self.max_entries = max_entries
|
||||
self.display = display
|
||||
self.context_args = context_args or set()
|
||||
|
||||
if self.required and (self.min_entries is None or self.min_entries < 1):
|
||||
self.min_entries = 1
|
||||
|
||||
def asdict(self, context: LayoutContext):
|
||||
return {
|
||||
**super().asdict(context),
|
||||
"content_type": self.content_type.value,
|
||||
"default_entries": [x.asdict() for x in self.default_entries],
|
||||
"display": self.display.value,
|
||||
"min_entries": self.min_entries,
|
||||
"max_entries": self.max_entries,
|
||||
"context_args": list(get_available_context(self.context_args, context['placeholder_renderer'].transformations)),
|
||||
}
|
||||
|
||||
def layout_schema(
|
||||
self,
|
||||
remaining_fields: list["FieldGroup"],
|
||||
context: LayoutContext,
|
||||
):
|
||||
content_type_placeholders = (
|
||||
context["placeholders"].get(self.content_type.value, {}).values()
|
||||
)
|
||||
renderer = context['placeholder_renderer']
|
||||
available_placeholders = [
|
||||
x.identifier
|
||||
for x in content_type_placeholders
|
||||
if renderer.is_available(x, self.context_args)
|
||||
]
|
||||
return {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"entries": self.entries_schema(placeholders=available_placeholders),
|
||||
"overflow": {
|
||||
"anyOf": [
|
||||
{"type": "null"},
|
||||
{
|
||||
"type": "string",
|
||||
"enum": [
|
||||
f.identifier
|
||||
for f in remaining_fields
|
||||
if isinstance(f, PlaceholderFieldGroup)
|
||||
and f.content_type == self.content_type
|
||||
],
|
||||
},
|
||||
]
|
||||
},
|
||||
},
|
||||
"required": ["entries"],
|
||||
}
|
||||
|
||||
def entries_schema(self, placeholders: list[str]):
|
||||
baseprops = {}
|
||||
if self.display == FieldGroupDisplay.WITH_LABEL:
|
||||
baseprops["label"] = {"$ref": "#/$defs/I18nString"}
|
||||
|
||||
schema = {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"anyOf": [
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": "placeholder"},
|
||||
"content": {"enum": placeholders},
|
||||
}
|
||||
},
|
||||
{
|
||||
"properties": {
|
||||
**baseprops,
|
||||
"type": {"const": "custom"},
|
||||
"content": {"$ref": "#/$defs/I18nString"},
|
||||
}
|
||||
},
|
||||
],
|
||||
"required": ["type", "content"],
|
||||
},
|
||||
}
|
||||
if self.display == FieldGroupDisplay.WITH_LABEL:
|
||||
schema["items"]["required"].append("label")
|
||||
if self.min_entries is not None:
|
||||
schema["minItems"] = self.min_entries
|
||||
# max_entries is not enforced here, as the layout can have more fields than that (null-fields are removed, rest is overspilled)
|
||||
return schema
|
||||
|
||||
|
||||
class TextFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.TEXT
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, **kwargs)
|
||||
|
||||
|
||||
class ImageFieldGroup(PlaceholderFieldGroup):
|
||||
content_type = FieldContentType.IMAGE
|
||||
display = FieldGroupDisplay.PLAIN
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(content_type=self.content_type, display=self.display, **kwargs)
|
||||
|
||||
|
||||
class SettingsField:
|
||||
identifier: str
|
||||
label: str
|
||||
type: Literal["image", "text"]
|
||||
help_text: str|None
|
||||
required: bool
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
identifier: str,
|
||||
label: str,
|
||||
type: Literal["image", "text"] = "text",
|
||||
help_text = None,
|
||||
required: bool = False,
|
||||
):
|
||||
self.identifier = identifier
|
||||
self.label = label
|
||||
self.type = type
|
||||
self.help_text = help_text
|
||||
self.required = required
|
||||
|
||||
def asdict(self):
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"label": self.label,
|
||||
"type": self.type,
|
||||
"help_text": self.help_text,
|
||||
"required": self.required,
|
||||
}
|
||||
|
||||
|
||||
class PassStyle:
|
||||
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
|
||||
# we evaluate the fields in this order, so they overspill in this order as well
|
||||
fieldgroups: list[FieldGroup]
|
||||
|
||||
@property
|
||||
def settings(self) -> list[SettingsField]:
|
||||
return []
|
||||
|
||||
@property
|
||||
def preview_layout(self) -> list | None:
|
||||
return None
|
||||
|
||||
def asdict(self):
|
||||
context = LayoutContext(placeholders=self.placeholders, placeholder_renderer=get_wallet_placeholder_renderer())
|
||||
return {
|
||||
"identifier": self.identifier,
|
||||
"name": self.name,
|
||||
"fieldgroups": [x.asdict(context) for x in self.fieldgroups],
|
||||
"preview_layout": self.preview_layout,
|
||||
"settings": [x.asdict() for x in self.settings],
|
||||
}
|
||||
|
||||
def layout_schema(self):
|
||||
context = LayoutContext(placeholders=self.placeholders, placeholder_renderer=get_wallet_placeholder_renderer())
|
||||
print(f"schema {self.settings=}")
|
||||
schema = {
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
# TODO: $id
|
||||
"title": self.name,
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"fieldgroups": {
|
||||
"description": "Layout Field Groups",
|
||||
"type": "object",
|
||||
"properties": {
|
||||
group.identifier: group.layout_schema(
|
||||
context=context, remaining_fields=self.fieldgroups[i:]
|
||||
)
|
||||
for (i, group) in enumerate(self.fieldgroups)
|
||||
},
|
||||
"required": [
|
||||
group.identifier for group in self.fieldgroups if group.required
|
||||
],
|
||||
},
|
||||
"settings": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
setting.identifier: {"type": ["string", "null"]}
|
||||
for setting in self.settings
|
||||
},
|
||||
"required": [
|
||||
setting.identifier for setting in self.settings if setting.required
|
||||
],
|
||||
}
|
||||
},
|
||||
"$defs": {
|
||||
"I18nString": {
|
||||
"oneOf": [
|
||||
{"type": "string"},
|
||||
{"type": "object", "additionalProperties": {"type": "string"}},
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
if any(group.required for group in self.fieldgroups):
|
||||
schema.setdefault("required", [])
|
||||
schema["required"].append("fieldgroups")
|
||||
# if any(setting.required for setting in self.settings):
|
||||
# schema.setdefault("required", [])
|
||||
# schema["required"].append("settings")
|
||||
|
||||
return schema
|
||||
|
||||
def render_placeholder(self, context, content_type, content):
|
||||
placeholder = self.placeholders.get(content_type, {}).get(content)
|
||||
if placeholder:
|
||||
placeholder_value = context.render_placeholder(placeholder)
|
||||
if placeholder_value:
|
||||
return placeholder.label, placeholder_value
|
||||
|
||||
return None, None
|
||||
|
||||
def __init__(self, event, layout = None, file_settings: dict[str, File] | None = None):
|
||||
self.event = event
|
||||
self.layout = layout
|
||||
self.file_settings = file_settings or {}
|
||||
self.placeholders = get_wallet_placeholders(self.event)
|
||||
|
||||
def validate(self):
|
||||
schema = self.layout_schema()
|
||||
try:
|
||||
jsonschema.validate(self.layout, schema)
|
||||
except jsonschema.ValidationError as e:
|
||||
raise ValidationError("Invalid layout: {}".format(str(e)))
|
||||
|
||||
def extract_file_settings(self, request, file_settings):
|
||||
res = {}
|
||||
for setting in self.settings:
|
||||
if setting.type == "image":
|
||||
if file_settings.get(setting.identifier) == "file:keep":
|
||||
res[setting.identifier] = "keep"
|
||||
elif data := file_settings.get(setting.identifier):
|
||||
res[setting.identifier] = handle_file_upload(data, request.user, getattr(request, "auth", None), {"image/png", "image/jpeg"})
|
||||
elif setting.identifier in file_settings:
|
||||
res[setting.identifier] = None
|
||||
|
||||
return res
|
||||
|
||||
def get_pass_fields(self, op: OrderPosition):
|
||||
if not self.layout:
|
||||
raise ValueError("`value` needs to be set")
|
||||
|
||||
context = get_wallet_placeholder_renderer(order_position=op)
|
||||
|
||||
fields = {}
|
||||
for group in self.fieldgroups:
|
||||
if isinstance(group, PredefinedFieldGroup):
|
||||
pass
|
||||
|
||||
elif isinstance(group, PlaceholderFieldGroup):
|
||||
group_fields = fields.get(group.identifier, [])
|
||||
if group.identifier in self.layout["fieldgroups"]:
|
||||
for field in self.layout["fieldgroups"][group.identifier][
|
||||
"entries"
|
||||
]:
|
||||
field_entry = {}
|
||||
if group.display == FieldGroupDisplay.WITH_LABEL:
|
||||
field_entry["label"] = LazyI18nString(field["label"])
|
||||
if field["type"] == FieldEntryType.PLACEHOLDER.value:
|
||||
label, field_entry["value"] = self.render_placeholder(
|
||||
context, group.content_type.value, field["content"]
|
||||
)
|
||||
if (
|
||||
group.display == FieldGroupDisplay.WITH_LABEL
|
||||
and not str(field_entry["label"])
|
||||
and label
|
||||
):
|
||||
field_entry["label"] = LazyI18nString(label)
|
||||
|
||||
elif field["type"] == FieldEntryType.CUSTOM.value:
|
||||
field_entry["value"] = LazyI18nString(field["content"])
|
||||
if "value" in field_entry and field_entry["value"]:
|
||||
group_fields.append(field_entry)
|
||||
if group.min_entries and len(group_fields) < group.min_entries:
|
||||
raise ValueError(
|
||||
f"Group {group.identifier} needs at least {group.min_entries} entries, but only {len(group_fields)} were provided"
|
||||
)
|
||||
fields[group.identifier] = group_fields[: group.max_entries]
|
||||
if overflow_group := self.layout["fieldgroups"][group.identifier][
|
||||
"overflow"
|
||||
]:
|
||||
fields.setdefault(overflow_group, [])
|
||||
fields[overflow_group] += group_fields[group.max_entries :]
|
||||
|
||||
else:
|
||||
raise ValueError("Unknown field group")
|
||||
return fields
|
||||
|
||||
def group_is_active(self, identifier: str):
|
||||
if not self.layout:
|
||||
raise ValueError("`value` needs to be set")
|
||||
|
||||
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
|
||||
|
||||
def generate(self, op: OrderPosition):
|
||||
raise NotImplementedError()
|
||||
@@ -0,0 +1,365 @@
|
||||
from pretix.base.models import Event, OrderPosition
|
||||
|
||||
from pretix.plugins.wallet.styles.base import (
|
||||
FieldGroupDisplay,
|
||||
ImageFieldGroup,
|
||||
PassStyle,
|
||||
PlaceholderFieldEntry,
|
||||
PredefinedFieldGroup,
|
||||
TextFieldGroup,
|
||||
WalletPlatform,
|
||||
)
|
||||
from django.utils.translation import gettext as _
|
||||
|
||||
from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject
|
||||
from walletobjects.comms import Comms
|
||||
from walletobjects.constants import (
|
||||
Barcode,
|
||||
ClassType,
|
||||
ConfirmationCode,
|
||||
DoorsOpen,
|
||||
MultipleDevicesAndHoldersAllowedStatus,
|
||||
ObjectState,
|
||||
ObjectType,
|
||||
ReviewStatus,
|
||||
Seat,
|
||||
AnimationType
|
||||
)
|
||||
from pretix.base.settings import GlobalSettingsObject
|
||||
import uuid
|
||||
from pretix.multidomain.urlreverse import eventreverse_absolute
|
||||
from django.utils import translation
|
||||
from django.conf import settings
|
||||
|
||||
|
||||
def _get_instance_uuid():
|
||||
gs = GlobalSettingsObject()
|
||||
if not gs.settings.wallet_google_instance_uuid:
|
||||
gs.settings.wallet_google_instance_uuid = str(uuid.uuid4())
|
||||
|
||||
return gs.settings.wallet_google_instance_uuid
|
||||
|
||||
|
||||
def get_class_id(event: Event, op: OrderPosition):
|
||||
instance_uuid = _get_instance_uuid()
|
||||
issuer_id = event.settings.get("wallet_google_issuer_id")
|
||||
return "%s.pretix-%s-%s-%s-%s-%s" % (
|
||||
issuer_id,
|
||||
instance_uuid,
|
||||
event.organizer.slug,
|
||||
event.slug,
|
||||
op.item_id,
|
||||
op.variation_id or 0
|
||||
)
|
||||
|
||||
|
||||
def get_object_id(op: OrderPosition):
|
||||
instance_uuid = _get_instance_uuid()
|
||||
issuer_id = op.order.event.settings.get("wallet_google_issuer_id")
|
||||
|
||||
return "%s.pretix-%s-%s-%s-%s-%s-1" % (
|
||||
issuer_id,
|
||||
instance_uuid,
|
||||
op.order.event.organizer.slug,
|
||||
op.order.event.slug,
|
||||
op.order.code,
|
||||
op.positionid,
|
||||
)
|
||||
|
||||
|
||||
def get_translated_dict(string, locales):
|
||||
translated = {}
|
||||
|
||||
for locale in locales:
|
||||
translation.activate(locale)
|
||||
translated[locale] = _(string)
|
||||
translation.deactivate()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
def get_translated_string(string, locale):
|
||||
translation.activate(locale)
|
||||
translated = _(string)
|
||||
translation.deactivate()
|
||||
|
||||
return translated
|
||||
|
||||
|
||||
class GooglePlatform(WalletPlatform):
|
||||
identifier = "google"
|
||||
name = _("Google")
|
||||
|
||||
|
||||
class GoogleWalletStyle(PassStyle):
|
||||
platform = GooglePlatform
|
||||
|
||||
def _generate_class(self):
|
||||
output_class = EventTicketClass(
|
||||
self.event.organizer.name,
|
||||
get_class_id(self.event, self.op),
|
||||
MultipleDevicesAndHoldersAllowedStatus.multipleHolders, # TODO: Make configurable
|
||||
self.event.name,
|
||||
ReviewStatus.underReview,
|
||||
self.event.settings.locale,
|
||||
)
|
||||
|
||||
output_class.homepage_uri(
|
||||
eventreverse_absolute(self.event, "presale:event.index"),
|
||||
get_translated_string("Website", self.event.settings.locale),
|
||||
get_translated_dict("Website", self.event.settings.locales),
|
||||
)
|
||||
|
||||
# TODO: callback url
|
||||
# output_class.callback_url(eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",))
|
||||
|
||||
# TODO: move to pass settings or set defaults
|
||||
# if (event.settings.get('ticketoutput_googlepaypasses_latitude')
|
||||
# and event.settings.get('ticketoutput_googlepaypasses_longitude')):
|
||||
# output_class.locations(
|
||||
# event.settings.get('ticketoutput_googlepaypasses_latitude'),
|
||||
# event.settings.get('ticketoutput_googlepaypasses_longitude')
|
||||
# )
|
||||
# elif event.geo_lat and event.geo_lon:
|
||||
# output_class.locations(
|
||||
# event.geo_lat,
|
||||
# event.geo_lon
|
||||
# )
|
||||
|
||||
# output_class.country_code(event.settings.locale)
|
||||
|
||||
# if event.settings.get('ticketoutput_googlepaypasses_hero'):
|
||||
# output_class.hero_image(
|
||||
# urljoin(django_settings.SITE_URL, event.settings.get('ticketoutput_googlepaypasses_hero').url),
|
||||
# str(event.name),
|
||||
# event.name,
|
||||
# )
|
||||
|
||||
# output_class.hex_background_color(event.settings.get('primary_color'))
|
||||
# output_class.event_id('pretix-%s-%s-%s' % (gs.settings.get('update_check_id'), event.organizer.id, event.id))
|
||||
|
||||
# if event.settings.get('ticketoutput_googlepaypasses_logo'):
|
||||
# output_class.logo(
|
||||
# urljoin(django_settings.SITE_URL, event.settings.get('ticketoutput_googlepaypasses_logo').url),
|
||||
# str(event.name),
|
||||
# event.name,
|
||||
# )
|
||||
|
||||
# if event.location:
|
||||
# name = {}
|
||||
# address = {}
|
||||
|
||||
# for key, value in event.location.data.items():
|
||||
# lines = value.splitlines()
|
||||
# name[key] = lines[0]
|
||||
# # We must provide at least one address line each for the name and address - no way around it.
|
||||
# if len(lines) > 1:
|
||||
# address[key] = '\n'.join(value.splitlines()[1:])
|
||||
# else:
|
||||
# address[key] = lines[0]
|
||||
|
||||
# output_class.venue(name, address)
|
||||
|
||||
# if event.date_from and event.date_to and event.date_admission:
|
||||
# output_class.date_time(
|
||||
# DoorsOpen.doorsOpen,
|
||||
# event.date_admission.isoformat(),
|
||||
# event.date_from.isoformat(),
|
||||
# event.date_to.isoformat(),
|
||||
# )
|
||||
|
||||
# output_class.confirmation_code_label(ConfirmationCode.orderNumber)
|
||||
|
||||
# if event.seating_plan_id is not None:
|
||||
# output_class.seat_label(Seat.seat)
|
||||
|
||||
# return self._comms().put_item(ClassType.eventTicketClass, class_name, output_class)
|
||||
return output_class
|
||||
|
||||
def _generate_object(self, op: OrderPosition, class_id: str):
|
||||
output_object = EventTicketObject(
|
||||
get_object_id(op), class_id, ObjectState.active, self.event.settings.locale
|
||||
)
|
||||
return output_object
|
||||
|
||||
def generate(self, op):
|
||||
self.op = op
|
||||
comms = Comms(self.event.settings.get("wallet_google_credentials").read())
|
||||
|
||||
class_object = self._generate_class()
|
||||
ticket_object = self._generate_object(op, class_id=class_object["id"])
|
||||
|
||||
# TODO: privacy screen
|
||||
class_object = comms.put_item(
|
||||
ClassType.eventTicketClass, class_object["id"], class_object
|
||||
)
|
||||
ticket_object = comms.put_item(
|
||||
ObjectType.eventTicketObject, ticket_object["id"], ticket_object
|
||||
)
|
||||
|
||||
generated_jwt = comms.sign_jwt(
|
||||
ButtonJWT(
|
||||
origins=[settings.SITE_URL],
|
||||
issuer=comms.client_email,
|
||||
event_ticket_objects=[ticket_object],
|
||||
skinny=True,
|
||||
)
|
||||
)
|
||||
|
||||
return (
|
||||
"googlepaypass",
|
||||
"text/uri-list",
|
||||
"https://pay.google.com/gp/v/save/%s" % generated_jwt,
|
||||
)
|
||||
|
||||
|
||||
class GoogleWalletEventTicket(GoogleWalletStyle):
|
||||
identifier = "event"
|
||||
name = "Event Ticket"
|
||||
fieldgroups = [
|
||||
ImageFieldGroup(
|
||||
identifier="logo",
|
||||
name=_("Logo"),
|
||||
min_entries=0,
|
||||
max_entries=1,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="poweredby",
|
||||
)
|
||||
],
|
||||
),
|
||||
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
|
||||
PredefinedFieldGroup(identifier="date", name=_("Date")),
|
||||
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
|
||||
TextFieldGroup(
|
||||
identifier="code",
|
||||
name=_("QR-Code"),
|
||||
max_entries=1,
|
||||
display=FieldGroupDisplay.CODE,
|
||||
default_entries=[
|
||||
PlaceholderFieldEntry(
|
||||
content="secret",
|
||||
)
|
||||
],
|
||||
context_args={"order_position"},
|
||||
),
|
||||
]
|
||||
|
||||
@property
|
||||
def preview_layout(self):
|
||||
return [
|
||||
[
|
||||
{
|
||||
"children": [
|
||||
{"fieldgroup": "logo", "relSize": 1},
|
||||
{
|
||||
"value": str(self.event.organizer.name),
|
||||
"relSize": 3,
|
||||
"display": ["large", "centered"],
|
||||
},
|
||||
]
|
||||
},
|
||||
{
|
||||
"children": [
|
||||
{
|
||||
"fieldgroup": "venue",
|
||||
"sample": [
|
||||
{"content": self.venue()[0], "label": ""},
|
||||
],
|
||||
},
|
||||
{"value": str(self.event.name), "display": "large"},
|
||||
],
|
||||
"direction": "column",
|
||||
"display": ["tight"],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "date",
|
||||
"sample": [
|
||||
{"content": "01/01/1970", "label": "Date"},
|
||||
{"content": "12:34", "label": "Time"},
|
||||
],
|
||||
},
|
||||
{
|
||||
"fieldgroup": "seating",
|
||||
"sample": [
|
||||
{"content": "5", "label": "Row"},
|
||||
{"content": "2", "label": "Seat"},
|
||||
],
|
||||
},
|
||||
{"fieldgroup": "code"},
|
||||
],
|
||||
[
|
||||
{
|
||||
"fieldgroup": "venue",
|
||||
"sample": [
|
||||
{"content": self.venue()[1], "label": self.venue()[0]},
|
||||
],
|
||||
},
|
||||
],
|
||||
]
|
||||
|
||||
def venue(self):
|
||||
if self.event.location:
|
||||
name = {}
|
||||
address = {}
|
||||
|
||||
for key, value in self.event.location.data.items():
|
||||
lines = value.splitlines()
|
||||
name[key] = lines[0]
|
||||
# We must provide at least one address line each for the name and address - no way around it.
|
||||
if len(lines) > 1:
|
||||
address[key] = "\n".join(value.splitlines()[1:])
|
||||
else:
|
||||
address[key] = lines[0]
|
||||
|
||||
return name, address
|
||||
return "", ""
|
||||
|
||||
def _generate_class(self):
|
||||
output_class = super()._generate_class()
|
||||
if self.group_is_active("venue") and all(self.venue()):
|
||||
output_class.venue(*self.venue())
|
||||
# TODO: hidden flag to enable:
|
||||
# output_class.securityAnimation(AnimationType.FOIL_SHIMMER)
|
||||
return output_class
|
||||
|
||||
def _generate_object(self, op: OrderPosition, class_id: str):
|
||||
output_object = super()._generate_object(op, class_id)
|
||||
fields = self.get_pass_fields(op)
|
||||
if fields["code"]:
|
||||
output_object.barcode(
|
||||
Barcode.qrCode, fields["code"][0]["value"], fields["code"][0]["value"]
|
||||
)
|
||||
|
||||
# output_object.reservation_info("%s-%s" % (op.order.event.slug, op.order.code))
|
||||
# output_object.ticket_holder_name(op.attendee_name or (op.addon_to.attendee_name if op.addon_to else ''))
|
||||
output_object.ticket_holder_name("Some name")
|
||||
output_object.ticket_number(fields["code"][0]["value"])
|
||||
# output_object.ticket_type(
|
||||
# get_translated_dict(
|
||||
# str(op.item) + (" – " + str(op.variation.value) if op.variation else ""),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
|
||||
# places = django_settings.CURRENCY_PLACES.get(op.order.event.currency, 2)
|
||||
# output_object.face_value(int(op.price * 1000 ** places), op.order.event.currency)
|
||||
|
||||
# if op.order.event.seating_plan_id is not None:
|
||||
# if op.seat:
|
||||
# output_object.seat(
|
||||
# get_translated_dict(
|
||||
# _(str(op.seat)),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
# else:
|
||||
# output_object.seat(
|
||||
# get_translated_dict(
|
||||
# _('General admission'),
|
||||
# op.order.event.settings.get('locales')
|
||||
# )
|
||||
# )
|
||||
|
||||
# return self._comms().put_item(ObjectType.eventTicketObject, object_name, output_object)
|
||||
return output_object
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "New layout" %}</h1>
|
||||
<form action="" method="post" class="form-horizontal">
|
||||
{% csrf_token %}
|
||||
{% bootstrap_form form layout="control" %}
|
||||
<div class="form-group">
|
||||
<label class="col-md-3 control-label">
|
||||
{% trans "Ticket design" %}
|
||||
</label>
|
||||
<div class="col-md-9 form-control-static">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You can modify the design after you saved this page.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group submit-group">
|
||||
<button type="submit" class="btn btn-primary btn-save">
|
||||
{% trans "Save" %}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
{% endblock %}
|
||||
@@ -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 %}
|
||||
@@ -0,0 +1,22 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load bootstrap3 %}
|
||||
{% load vite %}
|
||||
{% load static %}
|
||||
{% load compress %}
|
||||
|
||||
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h1>{% trans "Edit layout" %}</h1>
|
||||
{{ platforms|json_script:"platforms" }}
|
||||
{{ variables|json_script:"variables" }}
|
||||
{{ locales|json_script:"locales" }}
|
||||
<div id="editor" data-layout-id="{{ object.pk }}"></div>
|
||||
{% vite_hmr %}
|
||||
{% vite_asset "src/pretix/plugins/wallet/static/pretixplugins/wallet/main.ts" %}
|
||||
|
||||
{% csrf_token %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,74 @@
|
||||
{% extends "pretixcontrol/event/base.html" %}
|
||||
{% load i18n %}
|
||||
{% load money %}
|
||||
{% load wallet %}
|
||||
{% block title %}{% trans "Wallet layouts" %}{% endblock %}
|
||||
{% block content %}
|
||||
<h1>{% trans "Wallet layouts" %}</h1>
|
||||
{% if object_list|length == 0 %}
|
||||
<div class="empty-collection">
|
||||
<p>
|
||||
{% blocktrans trimmed %}
|
||||
You haven't created any layouts yet.
|
||||
{% endblocktrans %}
|
||||
</p>
|
||||
|
||||
{% if "event.settings.general:write" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}"
|
||||
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new layout" %}
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-quotas">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>{% trans "Name" %}</th>
|
||||
<th>{% trans "Default" %}</th>
|
||||
<th class="action-col-2"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for l in object_list %}
|
||||
<tr>
|
||||
<td>
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<strong><a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{{ l.name }}
|
||||
</a></strong>
|
||||
{% else %}
|
||||
<strong>{{ l.name }}</strong>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
{% if l.default %}
|
||||
<span class="text-success">
|
||||
<span class="fa fa-check"></span>
|
||||
{% trans "Default" %}
|
||||
</span>
|
||||
{% elif "can_change_event_settings" in request.eventpermset %}
|
||||
<form class="form-inline" method="post"
|
||||
action="{% url "plugins:wallet:default" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}">
|
||||
{% csrf_token %}
|
||||
<button class="btn btn-default btn-sm">
|
||||
{% trans "Make default" %}
|
||||
</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="text-right flip">
|
||||
{% if "can_change_event_settings" in request.eventpermset %}
|
||||
<a href="{% url "plugins:wallet:edit" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
|
||||
<a href="{% url "plugins:wallet:add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ l.id }}"
|
||||
class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
|
||||
<a href="{% url "plugins:wallet:delete" organizer=request.event.organizer.slug event=request.event.slug layout=l.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,8 @@
|
||||
{% load i18n %}
|
||||
<p>
|
||||
<a class="btn btn-primary btn-lg" target="_blank"
|
||||
href="{% url "plugins:wallet:index" organizer=request.organizer.slug event=request.event.slug %}">
|
||||
<span class="fa fa-paint-brush"></span>
|
||||
{% trans "Edit layouts" %}
|
||||
</a>
|
||||
</p>
|
||||
@@ -0,0 +1,10 @@
|
||||
from django import template
|
||||
|
||||
from ..models import WalletLayout
|
||||
|
||||
register = template.Library()
|
||||
|
||||
|
||||
@register.filter
|
||||
def platform_layouts(platform, event):
|
||||
return WalletLayout.objects.filter(event=event, platform=platform.identifier)
|
||||
@@ -0,0 +1,204 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
import logging
|
||||
from django.utils.translation import gettext as _
|
||||
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 pretix.plugins.wallet.styles.base import WalletPlatform
|
||||
from .styles.apple import ApplePlatform
|
||||
from .styles.google import GooglePlatform
|
||||
from collections import OrderedDict
|
||||
from django import forms
|
||||
from .forms import CertificateFileField, validate_rsa_privkey
|
||||
from pretix.control.forms import ClearableBasenameFileInput
|
||||
|
||||
logger = logging.getLogger("pretix.plugins.wallet")
|
||||
|
||||
|
||||
class WalletSettingsHolder(BaseTicketOutput):
|
||||
identifier = "wallet"
|
||||
verbose_name = _("Wallet Output")
|
||||
|
||||
is_meta = True
|
||||
is_enabled = False
|
||||
preview_allowed = (
|
||||
False # TODO: implement own preview view or hide button for meta-outputs
|
||||
)
|
||||
|
||||
def settings_content_render(self, request) -> str:
|
||||
return render_to_string(
|
||||
"pretixplugins/wallet/settings_content.html", {"request": request}
|
||||
)
|
||||
|
||||
|
||||
class WalletOutput(BaseTicketOutput):
|
||||
settings_form_fields = []
|
||||
platform: WalletPlatform
|
||||
|
||||
def __init__(self, event: Event):
|
||||
super().__init__(event)
|
||||
self.settings = SettingsSandbox(
|
||||
"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 platform_layout.pass_layout.generate(op)
|
||||
|
||||
|
||||
class GoogleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_google"
|
||||
verbose_name = _("Google")
|
||||
download_button_text = "Add to Google Wallet"
|
||||
platform = GooglePlatform
|
||||
is_cacheable = False
|
||||
|
||||
@property
|
||||
def confirmation_text(self) -> str | None:
|
||||
return _(
|
||||
"Please be aware, that contrary to other virtual wallets/passes (like Apple Wallet), Google Wallet Passes are not "
|
||||
"handled offline. Every pass that is created, has to be transmitted to Google Inc.\n"
|
||||
"\n"
|
||||
"By clicking \"Confirm\" below, we will transfer some of your personal information, which is "
|
||||
"necessary to provide you with your Google Wallet Pass, to Google Inc.\n"
|
||||
"\n"
|
||||
"Please be aware, that there is no way to delete the data, once it has been transmitted.\n")
|
||||
|
||||
def get_global_settings(sender, **kwargs):
|
||||
return OrderedDict(
|
||||
[
|
||||
(
|
||||
"wallet_google_issuer_id",
|
||||
forms.CharField(
|
||||
label=_("Google Wallet Issuer/Merchant ID"),
|
||||
help_text=_(
|
||||
# TODO: update text
|
||||
"After getting accepted by Google into the Google Pay API for Passes program, "
|
||||
"your Issuer ID can be found in the Merchant center at "
|
||||
"https://wallet.google.com/merchant/walletobjects/"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_google_credentials",
|
||||
forms.FileField(
|
||||
label=_("Google Wallet Service Account Credentials"),
|
||||
help_text=_(
|
||||
"Please paste the contents of the JSON credentials file "
|
||||
"of the service account you tied to your Google Pay API "
|
||||
"for Passes Issuer ID"
|
||||
),
|
||||
required=False,
|
||||
# TODO: add validator
|
||||
# validators=[validate_json_credentials]
|
||||
),
|
||||
),
|
||||
(
|
||||
"wallet_google_instance_uuid",
|
||||
forms.CharField(
|
||||
label=_("Google Wallet Pass Instance UUID"),
|
||||
help_text=_(
|
||||
"Instance-specific part to be added to the wallet ids"
|
||||
),
|
||||
required=False,
|
||||
),
|
||||
),
|
||||
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
class AppleWalletTicketOutput(WalletOutput):
|
||||
identifier = "wallet_apple"
|
||||
verbose_name = _("Apple")
|
||||
download_button_text = "Add to Apple Wallet"
|
||||
platform = ApplePlatform
|
||||
|
||||
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."
|
||||
),
|
||||
),
|
||||
),
|
||||
]
|
||||
)
|
||||
|
||||
OUTPUTS = [WalletSettingsHolder, GoogleWalletTicketOutput, AppleWalletTicketOutput]
|
||||
@@ -0,0 +1,50 @@
|
||||
#
|
||||
# This file is part of pretix (Community Edition).
|
||||
#
|
||||
# Copyright (C) 2014-2020 Raphael Michel and contributors
|
||||
# Copyright (C) 2020-today pretix GmbH and contributors
|
||||
#
|
||||
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
|
||||
# Public License as published by the Free Software Foundation in version 3 of the License.
|
||||
#
|
||||
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
|
||||
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
|
||||
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
|
||||
# this file, see <https://pretix.eu/about/en/license>.
|
||||
#
|
||||
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
|
||||
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
|
||||
# details.
|
||||
#
|
||||
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
|
||||
# <https://www.gnu.org/licenses/>.
|
||||
#
|
||||
from django.urls import re_path
|
||||
from pretix.api.urls import event_router
|
||||
|
||||
from .views import (
|
||||
LayoutEditorView,
|
||||
LayoutCreateView,
|
||||
LayoutListView,
|
||||
LayoutPreviewView,
|
||||
LayoutSetDefault,
|
||||
LayoutDelete
|
||||
)
|
||||
from .api import WalletLayoutViewSet
|
||||
|
||||
urlpatterns = [
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/$',
|
||||
LayoutListView.as_view(), name='index'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/add/$',
|
||||
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
|
||||
LayoutSetDefault.as_view(), name='default'),
|
||||
re_path(r'^control/event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/wallet/delete/(?P<layout>[^/]+)/$', # TODO
|
||||
LayoutDelete.as_view(), name='delete'),
|
||||
]
|
||||
|
||||
event_router.register('walletlayouts', WalletLayoutViewSet)
|
||||
@@ -0,0 +1,249 @@
|
||||
import copy
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from django.db import transaction
|
||||
from django import forms
|
||||
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, DeleteView, View
|
||||
from pretix_vrpayment_wero.payment import HttpRequest
|
||||
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,
|
||||
AVAILABLE_STYLES_DICT,
|
||||
)
|
||||
from django.contrib import messages
|
||||
from django.contrib.staticfiles import finders
|
||||
from django.utils.functional import cached_property
|
||||
from django.templatetags.static import static
|
||||
from .placeholders import get_wallet_placeholder_renderer, get_wallet_placeholders, WalletPlaceholderRenderer
|
||||
from pretix.base.middleware import add_to_response_csp
|
||||
|
||||
def get_editor_placeholders(event):
|
||||
with (
|
||||
rolledback_transaction(),
|
||||
language(event.settings.locale, event.settings.region),
|
||||
):
|
||||
p = get_preview_position(event)
|
||||
context = get_wallet_placeholder_renderer(order_position=p)
|
||||
placeholders = {
|
||||
t: {
|
||||
pid: {"label": str(p.label), "sample": str(context.render_sample(p)), "required_context": list(sorted(p.required_context))}
|
||||
for pid, p in ps.items()
|
||||
}
|
||||
for t, ps in get_wallet_placeholders(event).items()
|
||||
}
|
||||
return placeholders
|
||||
|
||||
|
||||
class WalletLayoutMixin:
|
||||
model = WalletLayout
|
||||
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 LayoutDetailView(WalletLayoutMixin, EventPermissionRequiredMixin, DetailView):
|
||||
pass
|
||||
|
||||
|
||||
class LayoutEditorView(LayoutDetailView):
|
||||
template_name = "pretixplugins/wallet/edit.html"
|
||||
|
||||
def get_context_data(self, **kwargs) -> dict[str, Any]:
|
||||
context = super().get_context_data(**kwargs)
|
||||
context["platforms"] = [
|
||||
{
|
||||
"identifier": platform.identifier,
|
||||
"name": platform.name,
|
||||
"styles": {
|
||||
style.identifier: style(self.request.event).asdict()
|
||||
for style in AVAILABLE_STYLES.get(platform.identifier)
|
||||
},
|
||||
}
|
||||
for platform in AVAILABLE_PLATFORMS
|
||||
]
|
||||
context["variables"] = get_editor_placeholders(self.request.event)
|
||||
context["locales"] = {
|
||||
l: dict(settings.LANGUAGES).get(l, l)
|
||||
for l in self.request.event.settings.get("locales")
|
||||
}
|
||||
|
||||
return context
|
||||
def dispatch(self, request: HttpRequest, *args: Any, **kwargs: Any) -> HttpResponse:
|
||||
response = super().dispatch(request, *args, **kwargs)
|
||||
add_to_response_csp(response, {
|
||||
'img-src': ['blob:'],
|
||||
})
|
||||
return response
|
||||
|
||||
class WalletLayoutCreateForm(forms.ModelForm):
|
||||
class Meta:
|
||||
model = WalletLayout
|
||||
fields = ("name",)
|
||||
|
||||
def __init__(self, *args, event, **kwargs):
|
||||
super().__init__(*args, **kwargs)
|
||||
self.event = event
|
||||
|
||||
def save(self, *args, **kwargs) -> Any:
|
||||
self.instance.event = self.event
|
||||
return super().save(*args, **kwargs)
|
||||
|
||||
|
||||
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:
|
||||
return reverse(
|
||||
"plugins:wallet:edit",
|
||||
kwargs={
|
||||
"organizer": self.request.event.organizer.slug,
|
||||
"event": self.request.event.slug,
|
||||
"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")
|
||||
file_settings = json.loads(request.POST.get("file_settings"))
|
||||
base_layout = event.wallet_layouts.get(pk=request.POST.get("id")).platform_layouts.filter(platform=platform_id).first()
|
||||
|
||||
if base_layout:
|
||||
base_file_settings = {fs.key: fs.file for fs in base_layout.file_settings.all()}
|
||||
else:
|
||||
base_file_settings = {}
|
||||
|
||||
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)
|
||||
l = style(event=event, layout=layout)
|
||||
file_settings = {**base_file_settings, **l.extract_file_settings(request, file_settings)}
|
||||
layout = style(event, layout, file_settings)
|
||||
layout.validate()
|
||||
|
||||
fname, mimet, data = layout.generate(p)
|
||||
resp = HttpResponse(data, content_type=mimet)
|
||||
ftype = fname.split(".")[-1]
|
||||
if not mimet.startswith("text/"):
|
||||
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())
|
||||
@@ -222,10 +222,11 @@
|
||||
{% if line in tickets_with_download %}
|
||||
{% for b in download_buttons %}
|
||||
<form action="{% if position_page and line.addon_to %}{% eventurl event "presale:event.order.position.download" secret=line.addon_to.web_secret order=order.code output=b.identifier pid=line.pk position=line.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=line.web_secret order=order.code output=b.identifier pid=line.pk position=line.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=line.pk %}{% endif %}"
|
||||
method="post" data-asynctask data-asynctask-download class="download-btn-form{% if b.javascript_required %} requirejs{% endif %}">
|
||||
method="post" data-asynctask data-asynctask-download class="download-btn-form{% if b.javascript_required or b.confirmation_text %} requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||
class="btn btn-sm {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
|
||||
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
|
||||
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.text }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{% load i18n %}
|
||||
{% load eventurl %}
|
||||
{% load rich_text %}
|
||||
{% if download_email_required %}
|
||||
<div class="panel panel-primary">
|
||||
<div class="panel-heading">
|
||||
@@ -43,10 +44,11 @@
|
||||
{% for b in download_buttons %}
|
||||
{% if b.multi %}
|
||||
<form action="{% eventurl event "presale:event.order.download.combined" secret=order.secret order=order.code output=b.identifier %}"
|
||||
method="post" data-asynctask data-asynctask-download class="download-btn-form">
|
||||
method="post" data-asynctask data-asynctask-download class="download-btn-form {% if b.javascript_required or b.confirmation_text %}requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
|
||||
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
|
||||
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.multi_text }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -62,11 +64,12 @@
|
||||
<div>
|
||||
{% for b in download_buttons %}
|
||||
<form action="{% if position_page and tickets_with_download.0.addon_to %}{% eventurl event "presale:event.order.position.download" secret=tickets_with_download.0.addon_to.web_secret order=order.code output=b.identifier pid=tickets_with_download.0.pk position=tickets_with_download.0.addon_to.positionid %}{% elif position_page %}{% eventurl event "presale:event.order.position.download" secret=tickets_with_download.0.web_secret order=order.code output=b.identifier pid=tickets_with_download.0.pk position=tickets_with_download.0.positionid %}{% else %}{% eventurl event "presale:event.order.download" secret=order.secret order=order.code output=b.identifier position=tickets_with_download.0.pk %}{% endif %}"
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="download-btn-form{% if b.javascript_required %} requirejs{% endif %}">
|
||||
method="post" data-asynctask data-asynctask-download
|
||||
class="download-btn-form {% if b.javascript_required or b.confirmation_text %}requirejs{% endif %}">
|
||||
{% csrf_token %}
|
||||
<button type="submit"
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}">
|
||||
class="btn btn-lg {% if b.identifier == "pdf" %}btn-primary{% else %}btn-default{% endif %}"
|
||||
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
|
||||
<span class="fa {{ b.icon }}" aria-hidden="true"></span> {{ b.long_text }}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
@@ -147,3 +147,14 @@
|
||||
{% dialog "dialog-nothing-to-add" label_nothing_to_add description_nothing_to_add icon="exclamation-circle" %}
|
||||
<p class="modal-card-confirm"><button class="btn btn-primary">{% trans "OK" %}</button></p>
|
||||
{% enddialog %}
|
||||
|
||||
{% trans "Confirmation required" as confirmation_title %}
|
||||
{% dialog "confirmationmodal" confirmation_title "" icon="shield" %}
|
||||
<div class="modal-card-confirm modal-card-confirm-spread">
|
||||
<button type="button" class="btn btn-default btn-lg" commandfor="confirmationmodal" command="close">{% trans "Cancel" %}</button>
|
||||
<button type="button" id="confirmationmodal-confirm"
|
||||
class="btn btn-primary btn-lg">
|
||||
{% trans "Confirm" %}
|
||||
</button>
|
||||
</div>
|
||||
{% enddialog %}
|
||||
@@ -42,7 +42,10 @@ import os
|
||||
import re
|
||||
from collections import Counter, OrderedDict, defaultdict
|
||||
from decimal import Decimal
|
||||
from typing import Iterable
|
||||
from urllib.parse import quote
|
||||
from datetime import timedelta
|
||||
from uuid import UUID
|
||||
|
||||
from django import forms
|
||||
from django.conf import settings
|
||||
@@ -61,12 +64,14 @@ from django.utils.timezone import now
|
||||
from django.utils.translation import gettext, gettext_lazy as _
|
||||
from django.views.decorators.clickjacking import xframe_options_exempt
|
||||
from django.views.generic import ListView, TemplateView, View
|
||||
from pretixeu.views import reverse
|
||||
|
||||
from pretix.base.auth import has_event_access_permission
|
||||
from pretix.base.models import (
|
||||
CachedTicket, Checkin, GiftCard, Invoice, Order, OrderPosition, Quota,
|
||||
TaxRule,
|
||||
)
|
||||
from pretix.base.models.base import CachedFile
|
||||
from pretix.base.models.orders import (
|
||||
CachedCombinedTicket, InvoiceAddress, OrderFee, OrderPayment, OrderRefund,
|
||||
QuestionAnswer,
|
||||
@@ -277,7 +282,8 @@ class TicketPageMixin:
|
||||
'icon': provider.download_button_icon or 'fa-download',
|
||||
'identifier': provider.identifier,
|
||||
'multi': provider.multi_download_enabled,
|
||||
'javascript_required': provider.javascript_required
|
||||
'javascript_required': provider.javascript_required,
|
||||
'confirmation_text': provider.confirmation_text,
|
||||
})
|
||||
return buttons
|
||||
|
||||
@@ -1176,9 +1182,6 @@ class AnswerDownload(EventViewMixin, OrderDetailMixin, View):
|
||||
|
||||
|
||||
class OrderDownloadMixin:
|
||||
def get_success_url(self, value):
|
||||
return self.get_self_url()
|
||||
|
||||
@cached_property
|
||||
def output(self):
|
||||
responses = register_ticket_outputs.send(self.request.event)
|
||||
@@ -1212,12 +1215,33 @@ class OrderDownloadMixin:
|
||||
):
|
||||
return self.error(OrderError(_('Please click the link we sent you via email to download your tickets.')))
|
||||
|
||||
ct = self.get_last_ct()
|
||||
if ct:
|
||||
if self.output.is_cacheable and (ct := self.get_last_ct()):
|
||||
return self.success(ct)
|
||||
|
||||
if not self.output.is_cacheable:
|
||||
cf = CachedFile(web_download=True, date=now(), expires=now() + timedelta(hours=2))
|
||||
cf.bind_to_session(request)
|
||||
cf.save()
|
||||
else:
|
||||
cf = None
|
||||
|
||||
return self.do('orderposition' if 'position' in kwargs else 'order',
|
||||
self.order_position.pk if 'position' in kwargs else self.order.pk,
|
||||
self.output.identifier)
|
||||
self.output.identifier,
|
||||
cf.pk if cf else None)
|
||||
|
||||
def get_success_url(self, value):
|
||||
if isinstance(value, UUID):
|
||||
cf = CachedFile.objects.filter(pk=value).first()
|
||||
else:
|
||||
cf = None
|
||||
if not cf or not cf.allowed_for_session(self.request):
|
||||
return self.get_self_url()
|
||||
|
||||
if cf.type == 'text/uri-list':
|
||||
return cf.file.file.read().decode()
|
||||
else:
|
||||
return reverse('cachedfile.download', kwargs={"id":str(value)})
|
||||
|
||||
def get_success_message(self, value):
|
||||
return ""
|
||||
@@ -1259,14 +1283,15 @@ class OrderDownloadMixin:
|
||||
return redirect(self.get_self_url())
|
||||
|
||||
def get_last_ct(self):
|
||||
if 'position' in self.kwargs:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
else:
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=self.order, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
if self.output.is_cacheable:
|
||||
if 'position' in self.kwargs:
|
||||
ct = CachedTicket.objects.filter(
|
||||
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
else:
|
||||
ct = CachedCombinedTicket.objects.filter(
|
||||
order=self.order, provider=self.output.identifier, file__isnull=False
|
||||
).last()
|
||||
if not ct or not ct.file:
|
||||
return None
|
||||
return ct
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
document.addEventListener('DOMContentLoaded', function () {
|
||||
document.querySelectorAll('a[href^="mailto:"]').forEach(function (link) {
|
||||
// Replace [at] with @ and the [dot] with . in both the href and the displayed text (if needed)
|
||||
link.href = link.href.replace('[at]', '@').replace('[dot]', '.')
|
||||
link.textContent = link.textContent.replace('[at]', '@').replace('[dot]', '.')
|
||||
link.href = link.href.replace('[at]', '@').replaceAll('[dot]', '.')
|
||||
link.textContent = link.textContent.replace('[at]', '@').replaceAll('[dot]', '.')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -233,7 +233,7 @@ let form_handlers = function (el) {
|
||||
|
||||
function setup_basics (el) {
|
||||
el.find('form').attr('novalidate', true).on('submit', function (e) {
|
||||
if (e.originalEvent.submitter.formNoValidate) return;
|
||||
if (e.originalEvent && e.originalEvent.submitter && e.originalEvent.submitter.formNoValidate) return;
|
||||
if (!this.checkValidity()) {
|
||||
let input = this.querySelector(':invalid:not(fieldset)');
|
||||
(input.labels[0] || input).scrollIntoView()
|
||||
@@ -730,6 +730,17 @@ $(function () {
|
||||
// Prevent double-submit, see also https://github.com/pretix/pretix/issues/5836
|
||||
$(this).addClass('disabled')
|
||||
})
|
||||
document.querySelectorAll('button[data-confirmation-text]').forEach((element) => {
|
||||
element.addEventListener('click', (evt) => {
|
||||
evt.preventDefault();
|
||||
const onConfirm = () => evt.target.form.submit();
|
||||
const confirmationModal = document.getElementById("confirmationmodal");
|
||||
confirmationModal.querySelector("#confirmationmodal-description").innerHTML = element.dataset.confirmationText
|
||||
confirmationModal.querySelector("#confirmationmodal-confirm").addEventListener('click', onConfirm, {'once': true})
|
||||
confirmationModal.addEventListener('close', () => confirmationModal.removeEventListener('click', onConfirm))
|
||||
confirmationModal.showModal()
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
function copy_answers (elements, answers) {
|
||||
|
||||
@@ -53,6 +53,9 @@
|
||||
.download-desktop {
|
||||
display: block;
|
||||
width: percentage((4 / $grid-columns));
|
||||
& .btn {
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
.product {
|
||||
width: percentage((4 / $grid-columns));
|
||||
@@ -205,7 +208,6 @@
|
||||
word-break: break-all;
|
||||
display: block;
|
||||
width: 100%;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -342,8 +342,11 @@ def test_invoice_shredder(event, order):
|
||||
s.shred_data()
|
||||
inv.refresh_from_db()
|
||||
|
||||
assert "Acme" not in inv.invoice_to
|
||||
assert "icket" not in inv.lines.first().description
|
||||
assert inv.invoice_to == "█"
|
||||
assert inv.invoice_to_company == "█"
|
||||
l = inv.lines.first()
|
||||
assert l.description == "█"
|
||||
assert l.attendee_name == "█"
|
||||
assert not inv.file
|
||||
assert not os.path.exists(fname)
|
||||
|
||||
|
||||
@@ -26,7 +26,7 @@ from django.template import Context, Template
|
||||
from django.test import RequestFactory
|
||||
from django.utils import translation
|
||||
|
||||
from pretix.base.templatetags.money import money_filter
|
||||
from pretix.base.templatetags.money import money_filter, tax_rate_format
|
||||
|
||||
TEMPLATE_REPLACE_PAGE = Template(
|
||||
"{% load urlreplace %}{% url_replace request 'page' 3 %}"
|
||||
@@ -60,7 +60,9 @@ def test_urlreplace_replace_parameter():
|
||||
"locale,amount,currency,expected",
|
||||
[
|
||||
("en", None, "USD", "$0.00"),
|
||||
("en", "", "USD", ""),
|
||||
("en", 1000000, "USD", "$1,000,000.00"),
|
||||
("en", 2.23, "USD", "$2.23"),
|
||||
("en", Decimal("1000.00"), "USD", "$1,000.00"),
|
||||
("de", Decimal("1.23"), "EUR", "1,23" + NBSP + "€"),
|
||||
("de", Decimal("1000.00"), "EUR", "1.000,00" + NBSP + "€"),
|
||||
@@ -70,11 +72,14 @@ def test_urlreplace_replace_parameter():
|
||||
|
||||
# unknown currency
|
||||
("de", Decimal("1234.56"), "FOO", "1.234,56" + NBSP + "FOO"),
|
||||
("de", Decimal("1234.567"), "FOO", "1.234,57" + NBSP + "FOO"),
|
||||
("de", Decimal("1234.567"), "FOO", "1.234,567" + NBSP + "FOO"),
|
||||
|
||||
# rounding errors
|
||||
("de", Decimal("1.234"), "EUR", "1,23" + NBSP + "€"),
|
||||
("de", Decimal("1023.1"), "JPY", "JPY 1.023,10"),
|
||||
# deal with precision that is higher than the currency
|
||||
("de", Decimal("1.234"), "EUR", "1,234" + NBSP + "€"),
|
||||
("de", 1.234, "EUR", "1,234" + NBSP + "€"),
|
||||
("de", Decimal("1.2340"), "EUR", "1,234" + NBSP + "€"),
|
||||
("de", Decimal("1.2300"), "EUR", "1,23" + NBSP + "€"),
|
||||
("de", Decimal("1023.1"), "JPY", "1.023,10" + NBSP + "¥"),
|
||||
]
|
||||
)
|
||||
def test_money_filter(locale, amount, currency, expected):
|
||||
@@ -98,9 +103,31 @@ def test_money_filter(locale, amount, currency, expected):
|
||||
[
|
||||
("de", Decimal("1000.00"), "EUR", "1.000,00"),
|
||||
("en", Decimal("1000.00"), "EUR", "1,000.00"),
|
||||
("de", Decimal("1023.1"), "JPY", "1.023,10"),
|
||||
("de", Decimal("1023.1"), "JPY", "1.023,1"),
|
||||
]
|
||||
)
|
||||
def test_money_filter_hidecurrency(locale, amount, currency, expected):
|
||||
translation.activate(locale)
|
||||
assert money_filter(amount, currency, hide_currency=True) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"locale,rate,expected",
|
||||
[
|
||||
("de", Decimal("2.00"), "2"),
|
||||
("de", Decimal("2.50"), "2,5"),
|
||||
("de", Decimal("2.2340"), "2,234"),
|
||||
("en", Decimal("2.00"), "2"),
|
||||
("en", Decimal("2.50"), "2.5"),
|
||||
("en", Decimal("4.3e7"), "43000000"),
|
||||
("en", Decimal("2.2340"), "2.234"),
|
||||
("en", "2.23", "2.23"),
|
||||
("en", 2.23, "2.23"),
|
||||
("en", 2, "2"),
|
||||
("en", "", ""),
|
||||
("en", None, "0"),
|
||||
]
|
||||
)
|
||||
def test_tax_rate_format(locale, rate, expected):
|
||||
translation.activate(locale)
|
||||
assert tax_rate_format(rate) == expected
|
||||
|
||||
@@ -0,0 +1,184 @@
|
||||
from pretix.plugins.wallet.styles.apple import SignedZipFile, StringResource, AppleWalletEventTicket
|
||||
from django.utils.translation import gettext as _
|
||||
import pytest
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography import x509
|
||||
import datetime
|
||||
import io
|
||||
import zipfile
|
||||
import json
|
||||
import jsonschema
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def pkpass_context():
|
||||
key_pw = b"TESTPW"
|
||||
now = datetime.datetime.now()
|
||||
ca_key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
ca_cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(
|
||||
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "TTDR")])
|
||||
)
|
||||
.issuer_name(
|
||||
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "ROOT Inc.")])
|
||||
)
|
||||
.public_key(ca_key.public_key())
|
||||
.serial_number(1)
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=365))
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
key = rsa.generate_private_key(public_exponent=65537, key_size=4096)
|
||||
cert = (
|
||||
x509.CertificateBuilder()
|
||||
.subject_name(
|
||||
x509.Name(
|
||||
[x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "UID=pass.test.test")]
|
||||
)
|
||||
)
|
||||
.issuer_name(
|
||||
x509.Name([x509.NameAttribute(x509.oid.NameOID.COMMON_NAME, "TTDR")])
|
||||
)
|
||||
.public_key(key.public_key())
|
||||
.serial_number(2)
|
||||
.not_valid_before(now)
|
||||
.not_valid_after(now + datetime.timedelta(days=365))
|
||||
.sign(ca_key, hashes.SHA256())
|
||||
)
|
||||
|
||||
ca_cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
|
||||
cert_pem = cert.public_bytes(encoding=serialization.Encoding.PEM)
|
||||
key_pem = key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.TraditionalOpenSSL,
|
||||
encryption_algorithm=serialization.BestAvailableEncryption(key_pw),
|
||||
)
|
||||
return {
|
||||
"ca_certificate": ca_cert_pem,
|
||||
"certificate": cert_pem,
|
||||
"key": key_pem,
|
||||
"password": key_pw,
|
||||
}
|
||||
|
||||
|
||||
def test_signed_zip(pkpass_context):
|
||||
pkpass = SignedZipFile(**pkpass_context)
|
||||
generated_pass = pkpass.finish()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
|
||||
assert set(zip_file.namelist()) == {"manifest.json", "signature"}
|
||||
with zip_file.open("manifest.json") as f:
|
||||
manifest = json.load(f)
|
||||
assert manifest == {}
|
||||
|
||||
with zip_file.open("signature") as f:
|
||||
signature = f.read()
|
||||
|
||||
assert signature
|
||||
|
||||
pkpass = SignedZipFile(**pkpass_context)
|
||||
pkpass.add_file("test", b"test content")
|
||||
generated_pass = pkpass.finish()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
|
||||
assert set(zip_file.namelist()) == {"test", "manifest.json", "signature"}
|
||||
with zip_file.open("manifest.json") as f:
|
||||
manifest = json.load(f)
|
||||
assert manifest == {"test": "1eebdf4fdc9fc7bf283031b93f9aef3338de9052"}
|
||||
|
||||
with zip_file.open("signature") as f:
|
||||
signature = f.read()
|
||||
|
||||
assert signature
|
||||
|
||||
pkpass = SignedZipFile(**pkpass_context)
|
||||
pkpass.add_file("test/test", "test content")
|
||||
generated_pass = pkpass.finish()
|
||||
|
||||
with zipfile.ZipFile(io.BytesIO(generated_pass), "r") as zip_file:
|
||||
assert set(zip_file.namelist()) == {"test/test", "manifest.json", "signature"}
|
||||
with zip_file.open("manifest.json") as f:
|
||||
manifest = json.load(f)
|
||||
assert manifest == {"test/test": "1eebdf4fdc9fc7bf283031b93f9aef3338de9052"}
|
||||
|
||||
with zip_file.open("signature") as f:
|
||||
signature = f.read()
|
||||
|
||||
assert signature
|
||||
|
||||
|
||||
def test_stringresource_minimal():
|
||||
resource = StringResource(locales=["de", "en"])
|
||||
resource.add_entry("TEST", LazyI18nString({"de": "test-de", "en": "test-en"}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"de", "en"}
|
||||
assert stringfiles["de"] == '"TEST" = "test-de";'
|
||||
assert stringfiles["en"] == '"TEST" = "test-en";'
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"input,output",
|
||||
[
|
||||
['te"st', 'te\\"st'],
|
||||
["te\rst", "te\\rst"],
|
||||
["te\nst", "te\\nst"],
|
||||
["te\r\nst", "te\\r\\nst"],
|
||||
["te\r\nst", "te\\r\\nst"],
|
||||
["te\\st", "te\\\\st"],
|
||||
],
|
||||
)
|
||||
def test_stringresource_escaping(input, output):
|
||||
resource = StringResource(locales=["en"])
|
||||
resource.add_entry("TEST", LazyI18nString({"en": input}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"en"}
|
||||
assert stringfiles["en"] == f'"TEST" = "{output}";'
|
||||
|
||||
resource = StringResource(locales=["en"])
|
||||
resource.add_entry(input, LazyI18nString({"en": "test"}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"en"}
|
||||
assert stringfiles["en"] == f'"{output}" = "test";'
|
||||
|
||||
|
||||
|
||||
def test_stringresource_additional_locale():
|
||||
resource = StringResource(locales=["de", "en", "fr"])
|
||||
resource.add_entry("TEST", LazyI18nString({"de": "test-de", "en": "test-en"}))
|
||||
stringfiles = resource.generate()
|
||||
|
||||
assert stringfiles.keys() == {"de", "en", "fr"}
|
||||
assert stringfiles["de"] == '"TEST" = "test-de";'
|
||||
assert stringfiles["en"] == '"TEST" = "test-en";'
|
||||
assert stringfiles["fr"] == '"TEST" = "test-en";'
|
||||
|
||||
def test_generate_pass_json():
|
||||
context = {
|
||||
"placeholders": {
|
||||
"text": {"test_placeholder": {"evaluate": lambda: "test placeholder"}}
|
||||
},
|
||||
"description": "Ticket for Test",
|
||||
"organizationName": "TestOrg",
|
||||
"serialNumber": "1",
|
||||
"passTypeIdentifier": "pass.test.test",
|
||||
"teamIdentifier": "ABCDEF123456"
|
||||
}
|
||||
layout = {"fieldgroups": {"primary": {"entries": [{"type": "placeholder", "label": "test", "content": "test_placeholder"}, {"type": "text", "label": {"de":"test-de", "en": "test-en"}, "content": "test content"}]}}}
|
||||
style = AppleWalletEventTicket()
|
||||
schema = style.layout_schema(context)
|
||||
jsonschema.validate(schema, layout)
|
||||
|
||||
result = style.generate_pass_json(layout, context)
|
||||
|
||||
required_fields = ["description", "formatVersion", "organizationName", "passTypeIdentifier", "serialNumber", "teamIdentifier"]
|
||||
for field in required_fields:
|
||||
assert field in result
|
||||
|
||||
assert result['formatVersion'] == 1
|
||||
@@ -0,0 +1,389 @@
|
||||
from pretix.plugins.wallet.styles.base import (
|
||||
PassStyle,
|
||||
PredefinedFieldGroup,
|
||||
WalletPlatform,
|
||||
PlaceholderFieldGroup,
|
||||
FieldContentType,
|
||||
FieldGroupType,
|
||||
FieldEntryType,
|
||||
FieldGroupDisplay
|
||||
)
|
||||
from django.utils.translation import gettext as _
|
||||
import jsonschema
|
||||
import pytest
|
||||
from i18nfield.strings import LazyI18nString
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from cryptography.hazmat.primitives import serialization, hashes
|
||||
from cryptography import x509
|
||||
import datetime
|
||||
import io
|
||||
import zipfile
|
||||
import json
|
||||
from pretix.plugins.wallet.placeholders import WalletPlaceholderRenderer, FunctionalContextTransformation, PlaceholderContextTransformation, get_available_context, get_transformed_context
|
||||
from pretix.base.models import Organizer, Event, Item, Order, User, InvoiceAddress
|
||||
from django_scopes import scopes_disabled, scope
|
||||
from django.utils.timezone import now
|
||||
from decimal import Decimal
|
||||
|
||||
class WalletTestPlatform(WalletPlatform):
|
||||
identifier = "test_platform"
|
||||
name = _("Test Wallet Platform")
|
||||
|
||||
|
||||
class MinimalTestStyle(PassStyle):
|
||||
platform = WalletTestPlatform
|
||||
identifier = "test_style"
|
||||
name = _("Test Wallet Style")
|
||||
fieldgroups = []
|
||||
|
||||
|
||||
class TicketTestStyle(PassStyle):
|
||||
platform = WalletTestPlatform
|
||||
identifier = "test_ticket"
|
||||
name = _("Test Wallet Style Ticket")
|
||||
fieldgroups = [
|
||||
PlaceholderFieldGroup(
|
||||
identifier="text1",
|
||||
name=_("Text 1"),
|
||||
content_type=FieldContentType.TEXT,
|
||||
required=True,
|
||||
),
|
||||
PlaceholderFieldGroup(
|
||||
identifier="text2",
|
||||
name=_("Text 2"),
|
||||
content_type=FieldContentType.TEXT,
|
||||
required=False,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
),
|
||||
PlaceholderFieldGroup(
|
||||
identifier="image1",
|
||||
name=_("Image 1"),
|
||||
content_type=FieldContentType.IMAGE,
|
||||
required=False,
|
||||
display=FieldGroupDisplay.PLAIN,
|
||||
),
|
||||
]
|
||||
|
||||
def generate(self, op):
|
||||
fields = self.get_pass_fields({})
|
||||
return fields
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def layout_context():
|
||||
return {
|
||||
"placeholders": {
|
||||
"text": {"test_placeholder": {"evaluate": lambda: "test placeholder"}}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
def test_schema_generation_minimal():
|
||||
style = MinimalTestStyle
|
||||
context = {}
|
||||
schema = style.layout_schema(context)
|
||||
assert isinstance(schema, dict)
|
||||
assert "properties" in schema
|
||||
assert "fieldgroups" in schema["properties"]
|
||||
|
||||
jsonschema.validate({}, schema)
|
||||
jsonschema.validate({"fieldgroups": {}}, schema)
|
||||
|
||||
|
||||
def test_schema_ticket_generation(layout_context):
|
||||
style = TicketTestStyle
|
||||
schema = style.layout_schema(layout_context)
|
||||
assert isinstance(schema, dict)
|
||||
assert "properties" in schema
|
||||
assert "fieldgroups" in schema["properties"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{"type": "text", "label": "test", "content": "test content"}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
},
|
||||
{"type": "text", "label": "test", "content": "test content"},
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
},
|
||||
{"type": "text", "label": "test", "content": "test content"},
|
||||
],
|
||||
"overflow": "text2",
|
||||
}
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_schema_ticket_valid(layout_context, layout):
|
||||
style = TicketTestStyle
|
||||
schema = style.layout_schema(layout_context)
|
||||
|
||||
jsonschema.validate(layout, schema)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"layout",
|
||||
[
|
||||
{},
|
||||
{"fieldgroups": {}},
|
||||
{"fieldgroups": {"text1": {}}},
|
||||
{"fieldgroups": {"text1": {"entries": []}}},
|
||||
{"fieldgroups": {"text1": {"overflow": "test"}}},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [{"type": "placeholder", "content": "test_placeholder"}]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": [],
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {"entries": [{"type": "text", "content": "test content"}]}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
"overflow": "invalid_group",
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
"overflow": "image1",
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
},
|
||||
"text2": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": "test",
|
||||
"content": "test_placeholder",
|
||||
}
|
||||
],
|
||||
"overflow": "text1",
|
||||
},
|
||||
}
|
||||
},
|
||||
],
|
||||
)
|
||||
def test_schema_ticket_invalid(layout_context, layout):
|
||||
style = TicketTestStyle
|
||||
schema = style.layout_schema(layout_context)
|
||||
|
||||
with pytest.raises(jsonschema.ValidationError):
|
||||
jsonschema.validate(layout, schema)
|
||||
|
||||
|
||||
def test_style_representation():
|
||||
style = TicketTestStyle
|
||||
style_dict = style.asdict()
|
||||
assert style_dict["platform"] == "test_platform"
|
||||
assert style_dict["identifier"] == "test_ticket"
|
||||
assert style_dict["name"] == _("Test Wallet Style Ticket")
|
||||
|
||||
assert style_dict["fieldgroups"][0]["identifier"] == "text1"
|
||||
assert style_dict["fieldgroups"][0]["name"] == "Text 1"
|
||||
assert style_dict["fieldgroups"][0]["content_type"] == "text"
|
||||
assert style_dict["fieldgroups"][0]["labels"] == True
|
||||
assert style_dict["fieldgroups"][0]["required"] == True
|
||||
|
||||
|
||||
def test_layout_generate(layout_context):
|
||||
style = TicketTestStyle
|
||||
layout = {
|
||||
"fieldgroups": {
|
||||
"text1": {
|
||||
"entries": [
|
||||
{
|
||||
"type": "placeholder",
|
||||
"label": {"de": "test-de", "en": "test-en"},
|
||||
"content": "test_placeholder",
|
||||
},
|
||||
{"type": "text", "label": "test", "content": "test content"},
|
||||
],
|
||||
"overflow": "text2",
|
||||
}
|
||||
}
|
||||
}
|
||||
# TODO: create event and pass here
|
||||
pass_layout = style(event=None, layout=layout)
|
||||
generated_pass = pass_layout.generate(layout_context)
|
||||
|
||||
assert (
|
||||
generated_pass
|
||||
== "Generated Pass: Test Wallet Style Ticket\n\nGroup: Text 1\ntest-en: test placeholder\ntest: test content\n\n"
|
||||
)
|
||||
|
||||
|
||||
def test_placeholder_transformation_graph():
|
||||
context = {"order_position"}
|
||||
trans1 = FunctionalContextTransformation("order", {"order_position"}, lambda order_position: order_position.order)
|
||||
trans2 = FunctionalContextTransformation("event", {"order"}, lambda order: order.event)
|
||||
trans3 = FunctionalContextTransformation("name", {"invoice_address"}, lambda invoice_address: "name")
|
||||
assert get_available_context(context, [trans1, trans2]) == {"order_position", "order", "event"}
|
||||
assert get_available_context(context, [trans2, trans1]) == {"order_position", "order", "event"}
|
||||
assert get_available_context(context, [trans1, trans2, trans3]) == {"order_position", "order", "event"}
|
||||
|
||||
@pytest.fixture
|
||||
def env():
|
||||
o = Organizer.objects.create(name='Dummy', slug='dummy')
|
||||
prop1 = o.meta_properties.get_or_create(name="Test")[0]
|
||||
prop2 = o.meta_properties.get_or_create(name="Website")[0]
|
||||
event = Event.objects.create(
|
||||
organizer=o, name='Dummy', slug='dummy',
|
||||
date_from=now()
|
||||
)
|
||||
event.meta_values.update_or_create(property=prop1, defaults={'value': "*Beep*"})
|
||||
event.meta_values.update_or_create(property=prop2, defaults={'value': "https://example.com"})
|
||||
user = User.objects.create_user('dummy@dummy.dummy', 'dummy')
|
||||
user.email = 'dummy@dummy.dummy'
|
||||
user.save()
|
||||
with scope(organizer=o):
|
||||
yield event, user, o
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@scopes_disabled()
|
||||
def item(env):
|
||||
return env[0].items.create(name="Budget Ticket", default_price=23)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
@scopes_disabled()
|
||||
def order(env, item):
|
||||
event, _, _ = env
|
||||
o = Order.objects.create(
|
||||
code="FOO",
|
||||
event=event,
|
||||
email="dummy@dummy.test",
|
||||
status=Order.STATUS_PENDING,
|
||||
secret="k24fiuwvu8kxz3y1",
|
||||
sales_channel=event.organizer.sales_channels.get(identifier="web"),
|
||||
datetime=datetime.datetime(2017, 12, 1, 10, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
expires=datetime.datetime(2017, 12, 10, 10, 0, 0, tzinfo=datetime.timezone.utc),
|
||||
total=23,
|
||||
locale="en",
|
||||
)
|
||||
o.positions.create(
|
||||
order=o,
|
||||
item=item,
|
||||
variation=None,
|
||||
price=Decimal("23"),
|
||||
attendee_email="peter@example.org",
|
||||
attendee_name_parts={"given_name": "Peter", "family_name": "Miller"},
|
||||
secret="z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
|
||||
pseudonymization_id="ABCDEFGHKL",
|
||||
)
|
||||
InvoiceAddress.objects.create(
|
||||
order=o,
|
||||
name_parts={"given_name": "Peter", "family_name": "Miller"},
|
||||
)
|
||||
return o
|
||||
|
||||
@pytest.mark.django_db
|
||||
def test_placeholder_transformation_calculation(order):
|
||||
op = order.positions.first()
|
||||
event = order.event
|
||||
context_args = {"order_position": op}
|
||||
trans1 = FunctionalContextTransformation("order", {"order_position"}, lambda order_position: order_position.order)
|
||||
trans2 = FunctionalContextTransformation("event", {"order"}, lambda order: order.event)
|
||||
trans3 = FunctionalContextTransformation("name", {"invoice_address"}, lambda invoice_address: "name")
|
||||
expected_context = {"order_position": op, "order": order, "event": event}
|
||||
assert get_transformed_context(context_args, [trans1, trans2]) == expected_context
|
||||
assert get_transformed_context(context_args, [trans2, trans1]) == expected_context
|
||||
assert get_transformed_context(context_args, [trans1, trans2, trans3]) == expected_context
|
||||
|
||||
@@ -478,6 +478,58 @@ class CheckoutTestCase(BaseCheckoutTestCase, TimemachineTestMixin, TestCase):
|
||||
assert ia.vat_id == "AT123456"
|
||||
assert not ia.vat_id_validated
|
||||
|
||||
def test_vat_id_revalidated_after_country_change(self):
|
||||
self.tr19.eu_reverse_charge = True
|
||||
self.tr19.keep_gross_if_rate_changes = True
|
||||
self.tr19.home_country = Country('DE')
|
||||
self.tr19.save()
|
||||
self.event.settings.invoice_address_vatid = True
|
||||
|
||||
with scopes_disabled():
|
||||
cr1 = CartPosition.objects.create(
|
||||
event=self.event, cart_id=self.session_key, item=self.ticket,
|
||||
price=23, expires=now() + timedelta(minutes=10)
|
||||
)
|
||||
|
||||
with mock.patch('pretix.base.services.tax._validate_vat_id_EU') as mock_validate:
|
||||
mock_validate.return_value = 'AT123456'
|
||||
self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||
'is_business': 'business',
|
||||
'company': 'Foo',
|
||||
'name': 'Bar',
|
||||
'street': 'Baz',
|
||||
'zipcode': '1234',
|
||||
'city': 'Here',
|
||||
'country': 'AT',
|
||||
'vat_id': 'AT123456',
|
||||
'email': 'admin@localhost',
|
||||
'transmission_type': 'email',
|
||||
}, follow=True)
|
||||
|
||||
cr1.refresh_from_db()
|
||||
assert cr1.price == Decimal('23.00')
|
||||
assert cr1.tax_rate == Decimal('0.00')
|
||||
assert cr1.tax_value == Decimal('0.00')
|
||||
|
||||
with mock.patch('pretix.base.services.tax._validate_vat_id_EU') as mock_validate:
|
||||
def raiser(*args, **kwargs):
|
||||
raise VATIDFinalError('country mismatch')
|
||||
|
||||
mock_validate.side_effect = raiser
|
||||
r = self.client.post('/%s/%s/checkout/questions/' % (self.orga.slug, self.event.slug), {
|
||||
'is_business': 'business',
|
||||
'company': 'Foo',
|
||||
'name': 'Bar',
|
||||
'street': 'Baz',
|
||||
'zipcode': '1012 WX',
|
||||
'city': 'Here',
|
||||
'country': 'NL',
|
||||
'vat_id': 'AT123456',
|
||||
'email': 'admin@localhost',
|
||||
'transmission_type': 'email',
|
||||
}, follow=True)
|
||||
assert b'alert-danger' in r.content
|
||||
|
||||
def test_reverse_charge_keep_gross(self):
|
||||
self.tr19.eu_reverse_charge = True
|
||||
self.tr19.keep_gross_if_rate_changes = True
|
||||
|
||||
+4
-2
@@ -3,7 +3,9 @@
|
||||
"src/pretix/static/**/*",
|
||||
"src/pretix/static/**/*.vue",
|
||||
"src/pretix/plugins/webcheckin/**/*",
|
||||
"src/pretix/plugins/webcheckin/**/*.vue"
|
||||
"src/pretix/plugins/webcheckin/**/*.vue",
|
||||
"src/pretix/plugins/wallet/**/*",
|
||||
"src/pretix/plugins/wallet/**/*.vue"
|
||||
],
|
||||
"compilerOptions": {
|
||||
"baseUrl": ".",
|
||||
@@ -11,7 +13,7 @@
|
||||
"strict": false,
|
||||
"allowJs": true,
|
||||
"checkJs": true,
|
||||
"target": "esnext",
|
||||
"target": "es2025",
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
|
||||
Reference in New Issue
Block a user