Compare commits

..
Author SHA1 Message Date
Kara Engelhardt 7fc6c2c60d missing file 2026-10-01 09:43:14 +02:00
Kara Engelhardt 98cab28657 button styling 2026-10-01 09:43:04 +02:00
Kara Engelhardt daec284a54 ticketoutput confirmation_text; is_cacheable 2026-09-30 16:56:09 +02:00
Kara Engelhardt 1a0758d7c7 bugfixes, start moving wallet to core 2026-09-22 16:31:47 +02:00
Kara Engelhardt 8327f65987 filesettings; placeholder context transformations; bugfixes 2026-09-22 15:52:15 +02:00
Kara Engelhardt caf475da8a WIP
proper fileupload handling, better store
2026-09-22 10:52:33 +02:00
Kara Engelhardt 8cd12206d4 WIP
mvp (file)settings
2026-09-22 10:52:33 +02:00
Kara Engelhardt 89cb024ef8 WIP
prettify & cleanup preview

support multiline fields in preview

add venue

store active state for predefined fieldgroups
2026-09-22 10:52:33 +02:00
Kara Engelhardt c796cf742d WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt de957b015e WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt a9c3f268bd WIP: predefined preview, google api, custom placeholder class 2026-09-22 10:52:33 +02:00
Kara Engelhardt 55b90fe88e WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt e1bb436add WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt 9f6896df05 WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt 8da2dfcf50 preview from server, qrcode in apple event pass 2026-09-22 10:52:33 +02:00
Kara Engelhardt e4321f204a WIP preview 2026-09-22 10:52:33 +02:00
Kara Engelhardt f8de7cc466 WIP preview 2026-09-22 10:52:33 +02:00
Kara Engelhardt f5b85a554a start preview 2026-09-22 10:52:33 +02:00
Kara Engelhardt 5b46124dc8 wallet wip 2026-09-22 10:52:33 +02:00
Kara Engelhardt 5675bcf749 Fix typo 2026-09-22 10:52:33 +02:00
Kara Engelhardt 62dbb8da15 vite dev server: do not clear screen 2026-09-22 10:52:33 +02:00
Kara Engelhardt 85e3a91546 WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt 4a89d50386 Remove static layout name from editor page 2026-09-22 10:52:33 +02:00
Kara Engelhardt 1ae4f37361 use placeholder name as label default 2026-09-22 10:52:33 +02:00
Kara Engelhardt eaff44e924 WIP: first successful pass creation 2026-09-22 10:52:33 +02:00
Kara Engelhardt 4837f791db WalletLayout now contain layouts for all platforms 2026-09-22 10:52:33 +02:00
Kara Engelhardt 9dc083cb88 WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt b80c7d68bb WIP: i18n editor, start apple wallet generation 2026-09-22 10:52:33 +02:00
Kara Engelhardt 326992dd6d WIP: i18nfields, refactoring, jsonschema-validatoin 2026-09-22 10:52:33 +02:00
Kara Engelhardt 3663754dfb WIP: use api 2026-09-22 10:52:33 +02:00
Kara Engelhardt 77a4929bdf WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt af15c7cdb7 WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt 4b76c4e8d7 WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt c8ec5d8367 WIP 2026-09-22 10:52:33 +02:00
Kara Engelhardt ed06acf580 WIP 2026-09-22 10:52:32 +02:00
Kara Engelhardt 3b998f8acc Add wallet plugins stub 2026-09-22 10:52:32 +02:00
Raphael Michel d77a179606 2FA: Show info on throttled request (#6581)
* 2FA: Show info on throttled request

* Round up the time
2026-09-22 10:38:26 +02:00
Raphael Michel 29614db1e2 InvoiceShredder: Fix shredding of new fields (#6577)
* InvoiceShredder: Fix shredding of new fields

* Update src/pretix/base/migrations/0311_fix_unshredded_invoices.py
2026-09-22 10:37:58 +02:00
Raphael Michel 365051cc21 Event metadata: Fix server-side validation of write protection (#6578) 2026-09-22 10:00:15 +02:00
Raphael Michel 5c6b0eef6f Plugin configuration: Allow organizers to see all events (#6576) 2026-09-22 09:51:18 +02:00
Raphael Michel bc0a6b662b Checkout: Fix VAT ID revalidation after country change (#6575) 2026-09-22 09:51:10 +02:00
dependabot[bot]andRichard Schreiber 0bc1aed1a0 Update pypdf requirement from ==6.11.* to ==6.19.* (#6570)
* Update pypdf requirement from ==6.11.* to ==6.18.*

Updates the requirements on [pypdf](https://github.com/py-pdf/pypdf) to permit the latest version.
- [Release notes](https://github.com/py-pdf/pypdf/releases)
- [Changelog](https://github.com/py-pdf/pypdf/blob/main/CHANGELOG.md)
- [Commits](https://github.com/py-pdf/pypdf/compare/6.11.0...6.18.1)

---
updated-dependencies:
- dependency-name: pypdf
  dependency-version: 6.18.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>

* Update pyproject.toml

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-09-22 09:38:28 +02:00
Rita Gimenez 0eaa5a081c Translations: Update Catalan
Currently translated at 29.4% (1892 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/ca/

powered by weblate
2026-09-21 14:56:27 +02:00
Nate Horst 688c434c4f Translations: Update Thai
Currently translated at 37.1% (2387 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/th/

powered by weblate
2026-09-21 14:56:27 +02:00
dependabot[bot]andRaphael Michel c0474604a1 Update isort requirement from ==8.0.* to ==9.0.* (#6510)
* Update isort requirement from ==8.0.* to ==9.0.*

Updates the requirements on [isort](https://github.com/PyCQA/isort) to permit the latest version.
- [Release notes](https://github.com/PyCQA/isort/releases)
- [Changelog](https://github.com/PyCQA/isort/blob/main/CHANGELOG.md)
- [Commits](https://github.com/PyCQA/isort/compare/8.0.0...9.0.1)

---
updated-dependencies:
- dependency-name: isort
  dependency-version: 9.0.1
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>

* Run isort

---------

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
Co-authored-by: Raphael Michel <michel@pretix.eu>
2026-09-21 14:56:14 +02:00
dependabot[bot] 1741d08c2c Update cryptography requirement from >=50.0.0 to >=50.0.1 (#6509)
Updates the requirements on [cryptography](https://github.com/pyca/cryptography) to permit the latest version.
- [Changelog](https://github.com/pyca/cryptography/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pyca/cryptography/compare/50.0.0...50.0.1)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:14:06 +02:00
dependabot[bot] 6bbd808aaa Bump postcss-selector-parser from 7.1.1 to 7.1.5 (#6513)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.1 to 7.1.5.
- [Release notes](https://github.com/postcss/postcss-selector-parser/releases)
- [Changelog](https://github.com/postcss/postcss-selector-parser/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss-selector-parser/compare/v7.1.1...7.1.5)

---
updated-dependencies:
- dependency-name: postcss-selector-parser
  dependency-version: 7.1.5
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:13:46 +02:00
dependabot[bot] aa90c5b3b1 Bump @humanfs/node from 0.16.7 to 0.16.8 (#6517)
Bumps [@humanfs/node](https://github.com/humanwhocodes/humanfs/tree/HEAD/packages/node) from 0.16.7 to 0.16.8.
- [Release notes](https://github.com/humanwhocodes/humanfs/releases)
- [Changelog](https://github.com/humanwhocodes/humanfs/blob/main/packages/node/CHANGELOG.md)
- [Commits](https://github.com/humanwhocodes/humanfs/commits/node-v0.16.8/packages/node)

---
updated-dependencies:
- dependency-name: "@humanfs/node"
  dependency-version: 0.16.8
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:13:39 +02:00
dependabot[bot] bfbabe0e58 Update fakeredis requirement from ==2.37.* to ==2.38.* (#6563)
Updates the requirements on [fakeredis](https://github.com/cunla/fakeredis-py) to permit the latest version.
- [Release notes](https://github.com/cunla/fakeredis-py/releases)
- [Commits](https://github.com/cunla/fakeredis-py/compare/v2.37.0...v2.38.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.38.0
  dependency-type: direct:development
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-21 14:08:32 +02:00
Raphael Michelandpajowu aa14505d2c Money representation in templates: Allow more precision (#6454)
* Money representation in templates: Allow more precision

When rendering money in templates, we used to have the following logic:

- When the decimal places fit the currency, render with Babel
- When they don't, e.g. we stored 123.67 JPY, even though there are no
  fractional Yens, render without Babel with a custom format, but render
  the fractional Yens because we'd rather *show* wrong data and make the
  bug obvious than hide it.

However, we only did that up to a prevision of two places, we never
showed more. This is still sufficient for core pretix, but we have
plugins that need to operate in fractional cents. Also, we CAN render
everything through babel for consistent formatting.

There is one **risk**: This might cause weird results on SQLite. Since
SQLite has no concept of precise decimal math, results of in-SQL
computations can sometimes experience floating point errors and show
with A LOT of decimal palces. This used to be invisible since the UI
performed the rounding. With this PR – not any more. We'll need to see
how annoying it is, but it should only affect development mode.

This PR also fixes a bug in tax_rate_format that for some reason did not
do what it was supposed to do, even though I tested it back then, weird.
Might even be a Python version thing?

* Update src/pretix/base/templatetags/money.py

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Apply suggestion from @pajowu

Co-authored-by: pajowu <engelhardt@pretix.eu>

* Fix typing stuff

* Fix precision issue

---------

Co-authored-by: pajowu <engelhardt@pretix.eu>
2026-09-18 11:49:12 +02:00
Kara Engelhardt df69656364 PDF: Fix background page being rotated multiple times
transfer_rotation_to_content (or rather add_transformation/replace_content) do not properly duplicate the contentstream they're modifying. this means that if you pass a pdf-level copy of the same bg_page multiple times, the content will be rotated multiple times but the rotation-property ('/Rotate') will only be changed for the first page

pypdfs docs explicitly say that you should not to add_transformation + merge, but instead use merge_transformed_page, which this now does for everything
2026-09-18 11:35:01 +02:00
Kara Engelhardt 82cffd1519 pdf: calculate sizebox based on cropbox and mediabox 2026-09-18 11:33:34 +02:00
Richard Schreiber 7fc527135d PDF: use artbox before trimbox before mediabox from background-PDF as size for canvas 2026-09-18 11:33:34 +02:00
Raphael Michel 8e39b67ee9 Fix missing translatable field label 2026-09-17 11:56:12 +02:00
dependabot[bot] 289cbe5bfc Update django-countries requirement from ==9.0.* to ==9.1.* (#6562)
Updates the requirements on [django-countries](https://github.com/SmileyChris/django-countries) to permit the latest version.
- [Changelog](https://github.com/SmileyChris/django-countries/blob/main/CHANGES.md)
- [Commits](https://github.com/SmileyChris/django-countries/compare/v9.0.0...v9.1.0)

---
updated-dependencies:
- dependency-name: django-countries
  dependency-version: 9.1.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-17 10:43:55 +02:00
Raphael Michel 7143b877c8 Translations: Update German
Currently translated at 100.0% (6419 of 6419 strings)

Translation: pretix/pretix
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix/de/

powered by weblate
2026-09-16 15:46:28 +02:00
dependabot[bot] e2a78100af Update pyjwt requirement from ==2.13.* to ==2.14.* (#6557)
Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.13.0...2.14.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.14.0
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:16 +02:00
dependabot[bot] ca77467e48 Update sentry-sdk requirement from ==2.68.* to ==2.69.* (#6556)
Updates the requirements on [sentry-sdk](https://github.com/getsentry/sentry-python) to permit the latest version.
- [Release notes](https://github.com/getsentry/sentry-python/releases)
- [Changelog](https://github.com/getsentry/sentry-python/blob/master/CHANGELOG.md)
- [Commits](https://github.com/getsentry/sentry-python/compare/2.68.0...2.69.1)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.69.1
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-09-16 15:46:07 +02:00
Raphael Michel d3cbfcd4da Invoice export: Add transmission-specific data (Z#23239732) (#6522) 2026-09-15 16:44:00 +02:00
Richard Schreiber 3648ef28d7 Fix deanonymizing emails with multiple dots (#6559) 2026-09-15 13:16:36 +02:00
Richard Schreiber 689b99c3aa PDF: fix inline merge_background on fg_pdf instead of its own PdfWriter (#6555)
* PDF: fix inline merge_background on fg_pdf instead of its own PdfWriter

* fix pdf_header

* fix flake8
2026-09-14 15:45:54 +02:00
Raphael Michel 6c40daa863 Hotfix for JS issue when submit() is called from JS 2026-09-14 15:33:25 +02:00
95 changed files with 5916 additions and 338 deletions
+2
View File
@@ -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'
+24 -10
View File
@@ -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
View File
@@ -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
View File
@@ -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",
+1
View File
@@ -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
+1
View File
@@ -66,6 +66,7 @@ INSTALLED_APPS = [
'pretix.plugins.returnurl',
'pretix.plugins.autocheckin',
'pretix.plugins.webcheckin',
'pretix.plugins.wallet',
'django_countries',
'oauth2_provider',
'phonenumber_field',
+25
View File
@@ -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
+8 -23
View 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:
+27 -4
View File
@@ -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'),
+7 -5
View File
@@ -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:
+1 -1
View File
@@ -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,
),
]
+1
View File
@@ -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
View File
@@ -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)
+67 -41
View File
@@ -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
View File
@@ -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):
+52 -23
View File
@@ -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,
)
+25 -1
View File
@@ -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:
"""
+3 -3
View File
@@ -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):
+2 -2
View File
@@ -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 }}"
+41 -6
View File
@@ -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):
+9 -4
View File
@@ -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
+4 -1
View File
@@ -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
+3 -3
View File
@@ -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 + '(,|$)')
}
+5
View File
@@ -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']
+9
View File
@@ -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):
+30 -23
View File
@@ -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 -5
View File
@@ -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"
+129 -100
View File
@@ -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 ""
+1 -4
View File
@@ -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
+21
View File
@@ -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/>.
#
+127
View File
@@ -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,
)
+41
View File
@@ -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
+101
View File
@@ -0,0 +1,101 @@
import logging
import re
import subprocess
import tempfile
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.utils.translation import gettext_lazy as _
from pretix.control.forms import ClearableBasenameFileInput
from django.core.files import File
logger = logging.getLogger(__name__)
def validate_rsa_privkey(value: File):
value = value.read().strip()
if isinstance(value, bytes):
value = value.decode()
if not value:
return
if not re.match(r"^-----BEGIN( (RSA |ENCRYPTED )?PRIVATE KEY-----).*-----END\1$", value, re.DOTALL):
raise ValidationError(
_(
"This does not look like an RSA private key in PEM format (it misses the correct begin or end signifiers)"
),
)
class CertificateFileField(forms.FileField):
widget = ClearableBasenameFileInput
def clean(self, value, *args, **kwargs):
value = super().clean(value, *args, **kwargs)
if isinstance(value, UploadedFile):
value.open("rb")
value.seek(0)
content = value.read()
if (
content.startswith(b"-----BEGIN CERTIFICATE-----")
and b"-----BEGIN CERTIFICATE-----" in content
):
return SimpleUploadedFile("cert.pem", content, "text/plain")
openssl_cmd = [
"openssl",
"x509",
"-inform",
"DER",
"-outform",
"PEM",
]
process = subprocess.Popen(
openssl_cmd,
stderr=subprocess.PIPE,
stdout=subprocess.PIPE,
stdin=subprocess.PIPE,
)
process.stdin.write(content)
pem, error = process.communicate()
if process.returncode != 0:
logger.info("Trying to convert a DER to PEM failed: {}".format(error))
raise ValidationError(
_(
"This does not look like a X509 certificate in either PEM or DER format"
),
)
return SimpleUploadedFile("cert.pem", pem, "text/plain")
return value
class PNGImageField(forms.FileField):
widget = ClearableBasenameFileInput
def clean(self, value, *args, **kwargs):
value = super().clean(value, *args, **kwargs)
if isinstance(value, UploadedFile):
try:
from PIL import Image
except ImportError:
return value
value.open("rb")
value.seek(0)
try:
with (
Image.open(value, formats=settings.PILLOW_FORMATS_IMAGE) as im,
tempfile.NamedTemporaryFile("rb", suffix=".png") as tmpfile,
):
im.save(tmpfile.name)
tmpfile.seek(0)
return SimpleUploadedFile(
"picture.png", tmpfile.read(), "image png"
)
except IOError:
logger.exception("Could not convert image to PNG.")
raise ValidationError(
_("The file you uploaded could not be converted to PNG format.")
)
return value
@@ -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),
),
]
@@ -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",
),
),
],
),
]
+102
View File
@@ -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()
+421
View File
@@ -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
]
+37
View File
@@ -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()
+39
View File
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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>
@@ -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"]
+405
View File
@@ -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
+460
View File
@@ -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()
+365
View File
@@ -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)
+204
View File
@@ -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]
+50
View File
@@ -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)
+249
View File
@@ -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 %}
+40 -15
View File
@@ -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]', '.')
})
})
+12 -1
View File
@@ -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;
}
}
+5 -2
View File
@@ -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)
+33 -6
View File
@@ -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
+184
View File
@@ -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
+389
View File
@@ -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
+52
View File
@@ -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
View File
@@ -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,