Compare commits

...
114 Commits
Author SHA1 Message Date
Kara Engelhardt 5092fafb40 no details limit 2026-10-15 18:26:00 +02:00
Kara Engelhardt 2fe97a2ee5 Start with custom google pass layout 2026-10-15 18:24:27 +02:00
Kara Engelhardt 896e7dac72 File settings handling; nicer file upload button 2026-10-15 16:42:16 +02:00
Kara Engelhardt 043e2a1b8e Bugfixes 2026-10-15 14:54:12 +02:00
Kara Engelhardt 276bb34528 cleanup 2026-10-15 14:33:15 +02:00
Kara Engelhardt 99b1b4cfe2 google: ticket type in preview 2026-10-15 14:33:15 +02:00
Kara Engelhardt 97153d3b4c google: show website 2026-10-15 14:33:15 +02:00
Kara Engelhardt c794e199f4 bugfixing; update types 2026-10-15 14:33:15 +02:00
Kara Engelhardt 4bc5b2941b cleanup 2026-10-15 14:33:15 +02:00
Kara Engelhardt 4a9c2ef20c localization; bugfixes; google event ticket 2026-10-15 14:33:15 +02:00
Kara Engelhardt 2ff639ae1e continue google event ticket 2026-10-15 14:33:15 +02:00
Kara Engelhardt e8ce084e29 Background preview 2026-10-15 14:33:15 +02:00
Kara Engelhardt 7e2922afd3 show default as first layout 2026-10-15 14:33:15 +02:00
Kara Engelhardt 82fe80f5e0 formatting 2026-10-15 14:33:15 +02:00
Kara Engelhardt a7fc470c5b Show default placeholder label 2026-10-15 14:33:15 +02:00
Kara Engelhardt 30e306f46d Add default for ImageSettingsField 2026-10-15 14:33:15 +02:00
Kara Engelhardt 336bd10691 Continue apple pass; Add float and color settings 2026-10-15 14:33:15 +02:00
Kara Engelhardt e29d27a343 add more fields for apple default pass layout 2026-10-15 14:33:15 +02:00
Kara Engelhardt 3a2bc0dec0 Add ticketoutput confirmation to control 2026-10-15 14:33:15 +02:00
Kara Engelhardt 8ae090c2da add wallet layout chooser to item settings 2026-10-15 14:33:15 +02:00
Kara Engelhardt 966b05dab2 fix layout settings styling, image setting preview, clear image 2026-10-15 14:33:15 +02:00
Kara Engelhardt a990f69a3f cleanup wallet placeholder signal & image placeholder handling 2026-10-15 14:33:15 +02:00
Kara Engelhardt 4c47bc0c28 add missing import 2026-10-15 14:33:15 +02:00
Kara Engelhardt 6a2a846766 fix walletsettings panel header spacing 2026-10-15 14:33:15 +02:00
Kara Engelhardt 41269ec406 show max_entries in overflow help text 2026-10-15 14:33:15 +02:00
Kara Engelhardt a37225343d missing file 2026-10-15 14:33:15 +02:00
Kara Engelhardt 6fe0f5b1c6 button styling 2026-10-15 14:33:15 +02:00
Kara Engelhardt 93ffea78ec ticketoutput confirmation_text; is_cacheable 2026-10-15 14:33:15 +02:00
Kara Engelhardt 1247557714 bugfixes, start moving wallet to core 2026-10-15 14:33:15 +02:00
Kara Engelhardt 6cb329cacd filesettings; placeholder context transformations; bugfixes 2026-10-15 14:33:15 +02:00
Kara Engelhardt 832f314df9 WIP
proper fileupload handling, better store
2026-10-15 14:33:15 +02:00
Kara Engelhardt 81410289be WIP
mvp (file)settings
2026-10-15 14:33:15 +02:00
Kara Engelhardt 0df51cbf67 WIP
prettify & cleanup preview

support multiline fields in preview

add venue

store active state for predefined fieldgroups
2026-10-15 14:33:15 +02:00
Kara Engelhardt d40112c730 WIP 2026-10-15 14:33:15 +02:00
Kara Engelhardt 7c5155b0f9 WIP 2026-10-15 14:33:15 +02:00
Kara Engelhardt 4d27452725 WIP: predefined preview, google api, custom placeholder class 2026-10-15 14:33:15 +02:00
Kara Engelhardt 273c4493ad WIP 2026-10-15 14:33:15 +02:00
Kara Engelhardt 50211faee9 WIP 2026-10-15 14:33:15 +02:00
Kara Engelhardt b63dbf9166 WIP 2026-10-15 14:33:15 +02:00
Kara Engelhardt 716a0f30f7 preview from server, qrcode in apple event pass 2026-10-15 14:33:15 +02:00
Kara Engelhardt 261d8cd2b8 WIP preview 2026-10-15 14:33:15 +02:00
Kara Engelhardt c0b3743ee4 WIP preview 2026-10-15 14:33:15 +02:00
Kara Engelhardt 903f2bcca8 start preview 2026-10-15 14:33:15 +02:00
Kara Engelhardt d7943f37f2 wallet wip 2026-10-15 14:33:14 +02:00
Kara Engelhardt 20a0ae9e49 Fix typo 2026-10-15 14:33:14 +02:00
Kara Engelhardt 3ffd66484c vite dev server: do not clear screen 2026-10-15 14:33:14 +02:00
Kara Engelhardt 2c42e4db2e WIP 2026-10-15 14:33:14 +02:00
Kara Engelhardt d84f4f0103 Remove static layout name from editor page 2026-10-15 14:33:14 +02:00
Kara Engelhardt ad0cbbc003 use placeholder name as label default 2026-10-15 14:33:14 +02:00
Kara Engelhardt 0d41dbf468 WIP: first successful pass creation 2026-10-15 14:33:14 +02:00
Kara Engelhardt d52ed0676b WalletLayout now contain layouts for all platforms 2026-10-15 14:33:14 +02:00
Kara Engelhardt 58240068c3 WIP 2026-10-15 14:33:14 +02:00
Kara Engelhardt e2882f7617 WIP: i18n editor, start apple wallet generation 2026-10-15 14:33:14 +02:00
Kara Engelhardt 34b53402a8 WIP: i18nfields, refactoring, jsonschema-validatoin 2026-10-15 14:33:14 +02:00
Kara Engelhardt 7800591681 WIP: use api 2026-10-15 14:33:14 +02:00
Kara Engelhardt 9a417a0fb0 WIP 2026-10-15 14:33:14 +02:00
Kara Engelhardt 4a1e86e21d WIP 2026-10-15 14:33:14 +02:00
Kara Engelhardt 91d9511e3e WIP 2026-10-15 14:33:14 +02:00
Kara Engelhardt 637d46471d WIP 2026-10-15 14:33:14 +02:00
Kara Engelhardt e4aa11caea WIP 2026-10-15 14:33:14 +02:00
Kara Engelhardt 53fa2974f4 Add wallet plugins stub 2026-10-15 14:33:14 +02:00
Martin Gross 2e4e60db34 Stripe: Enable Wero 2026-10-14 10:10:44 +02:00
Raphael Michel 6b7f2ad684 API: Fix PATCH on item addon sets without min_count or max_count (fixes #6650) (#6651) 2026-10-10 09:53:49 +02:00
dependabot[bot] 54b249acf5 Update pycryptodome requirement from ==3.23.* to ==3.24.* (#6657)
Updates the requirements on [pycryptodome](https://github.com/Legrandin/pycryptodome) to permit the latest version.
- [Release notes](https://github.com/Legrandin/pycryptodome/releases)
- [Changelog](https://github.com/Legrandin/pycryptodome/blob/master/Changelog.rst)
- [Commits](https://github.com/Legrandin/pycryptodome/compare/v3.23.0...v3.24.0)

---
updated-dependencies:
- dependency-name: pycryptodome
  dependency-version: 3.24.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-10-09 22:56:43 +02:00
dependabot[bot] 0cf2ba763b Update sphinxcontrib-httpdomain requirement from ~=2.0.0 to ~=2.1.0 (#6660)
Updates the requirements on [sphinxcontrib-httpdomain](https://github.com/sphinx-contrib/httpdomain) to permit the latest version.
- [Release notes](https://github.com/sphinx-contrib/httpdomain/releases)
- [Changelog](https://github.com/sphinx-contrib/httpdomain/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/sphinx-contrib/httpdomain/compare/2.0.0...2.1.0)

---
updated-dependencies:
- dependency-name: sphinxcontrib-httpdomain
  dependency-version: 2.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-10-09 22:56:27 +02:00
dependabot[bot] b44c3d707e Update fakeredis requirement from ==2.38.* to ==2.39.* (#6654)
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.38.0...v2.39.0)

---
updated-dependencies:
- dependency-name: fakeredis
  dependency-version: 2.39.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-10-09 22:56:17 +02:00
dependabot[bot] 45603c6624 Update css-inline requirement from ==0.21.* to ==0.22.* (#6655)
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/c-v0.21.0...c-v0.22.0)

---
updated-dependencies:
- dependency-name: css-inline
  dependency-version: 0.22.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-10-09 22:56:13 +02:00
Richard Schreiber ba36d649c6 Fix sendmail migration activating plugin on events (#6656) 2026-10-08 12:35:41 +02:00
Richard Schreiber ca90b2af09 Fix prefix for event notifications (#6653) 2026-10-08 10:09:38 +02:00
Phin Wolkwitz fe43862fa4 Sendmail: Handle scheduled mails in inactive plugin (Z#23243588) (#6587)
* Activate plugin where rules are currently active (migration)
* Check if plugin is active before sending mails
* Disable rules when the plugin gets (manually) uninstalled
2026-10-07 10:33:16 +02:00
Raphael MichelandRichard Schreiber 411cd764a6 Order changes: Log more information about taxes (#6637)
* Order changes: Log more information about taxes

* Apply batched suggestions from code review

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Add to tax_rule change as well

---------

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2026-10-07 09:28:35 +02:00
6618e3a039 Organizer-level notifications (#6462)
* Organizer-level notifications

* Update signals

* Update signal docs

* Add tests

* Apply batched suggestions from code review

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>

* Update src/pretix/base/models/organizer.py

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-10-07 09:28:15 +02:00
Richard Schreiber b7cd97c33e API: fix payment confirm if request.data is empty string (#6634)
* API: fix payment confirm if request.data is empty string

* fix fallback value
2026-10-07 09:21:30 +02:00
dependabot[bot] eae3229e68 Update pytest-mock requirement from ==3.15.* to ==3.16.* (#6628)
Updates the requirements on [pytest-mock](https://github.com/pytest-dev/pytest-mock) to permit the latest version.
- [Release notes](https://github.com/pytest-dev/pytest-mock/releases)
- [Changelog](https://github.com/pytest-dev/pytest-mock/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest-mock/compare/v3.15.0...v3.16.0)

---
updated-dependencies:
- dependency-name: pytest-mock
  dependency-version: 3.16.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-10-07 09:13:05 +02:00
Raphael Michel d291cd02e7 Docs: Fix invalid example of i18n string 2026-10-07 08:55:26 +02:00
dependabot[bot] e5dcb50229 Bump @vue/server-renderer and vue (#6646)
Bumps [@vue/server-renderer](https://github.com/vuejs/core/tree/HEAD/packages/server-renderer) to 3.5.43 and updates ancestor dependency [vue](https://github.com/vuejs/core). These dependencies need to be updated together.


Updates `@vue/server-renderer` from 3.5.30 to 3.5.43
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/commits/v3.5.43/packages/server-renderer)

Updates `vue` from 3.5.30 to 3.5.43
- [Release notes](https://github.com/vuejs/core/releases)
- [Changelog](https://github.com/vuejs/core/blob/main/CHANGELOG.md)
- [Commits](https://github.com/vuejs/core/compare/v3.5.30...v3.5.43)

---
updated-dependencies:
- dependency-name: "@vue/server-renderer"
  dependency-version: 3.5.43
  dependency-type: indirect
- dependency-name: vue
  dependency-version: 3.5.43
  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-10-07 08:54:01 +02:00
dependabot[bot] 9d333bc2cf Update cryptography requirement from >=50.0.1 to >=50.0.2 (#6648)
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.1...50.0.2)

---
updated-dependencies:
- dependency-name: cryptography
  dependency-version: 50.0.2
  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-10-06 22:33:33 +02:00
dependabot[bot] 45038d07f3 Bump django-filter from 26.1 to 26.2 (#6647)
Bumps [django-filter](https://github.com/carltongibson/django-filter) from 26.1 to 26.2.
- [Release notes](https://github.com/carltongibson/django-filter/releases)
- [Changelog](https://github.com/carltongibson/django-filter/blob/main/CHANGES.rst)
- [Commits](https://github.com/carltongibson/django-filter/compare/26.1...26.2)

---
updated-dependencies:
- dependency-name: django-filter
  dependency-version: '26.2'
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 22:33:30 +02:00
dependabot[bot] 00f0dfec88 Bump postcss-selector-parser from 7.1.5 to 7.1.6 (#6644)
Bumps [postcss-selector-parser](https://github.com/postcss/postcss-selector-parser) from 7.1.5 to 7.1.6.
- [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/7.1.5...7.1.6)

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

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 22:30:52 +02:00
dependabot[bot] 1975a9d891 Bump source-map-js from 1.2.1 to 1.2.2 (#6645)
Bumps [source-map-js](https://github.com/7rulnik/source-map-js) from 1.2.1 to 1.2.2.
- [Release notes](https://github.com/7rulnik/source-map-js/releases)
- [Changelog](https://github.com/7rulnik/source-map-js/blob/main/CHANGELOG.md)
- [Commits](https://github.com/7rulnik/source-map-js/compare/v1.2.1...v1.2.2)

---
updated-dependencies:
- dependency-name: source-map-js
  dependency-version: 1.2.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-10-06 22:30:42 +02:00
dependabot[bot] 02b9b6941d Bump smol-toml from 1.7.1 to 1.9.0 (#6643)
Bumps [smol-toml](https://github.com/squirrelchat/smol-toml) from 1.7.1 to 1.9.0.
- [Release notes](https://github.com/squirrelchat/smol-toml/releases)
- [Commits](https://github.com/squirrelchat/smol-toml/compare/v1.7.1...v1.9.0)

---
updated-dependencies:
- dependency-name: smol-toml
  dependency-version: 1.9.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-10-06 22:30:34 +02:00
dependabot[bot] da2ac6c386 Update tlds requirement from >=2026072401 to >=2026093003 (#6642)
Updates the requirements on [tlds](https://github.com/kichik/tlds) to permit the latest version.
- [Commits](https://github.com/kichik/tlds/commits)

---
updated-dependencies:
- dependency-name: tlds
  dependency-version: '2026093003'
  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-10-06 11:39:19 +02:00
Hijiri Umemoto b9fbe699ab Translations: Update Japanese
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Oksigenia 8e3f3cc8c8 Translations: Update Spanish
Currently translated at 100.0% (260 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/es/

powered by weblate
2026-10-06 11:04:53 +02:00
Oksigenia 945f80b288 Translations: Update Spanish
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Translate pretix user 594 25799da70d Translations: Update Russian
Currently translated at 25.9% (1672 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Nate Horst 6b01023d16 Translations: Update Thai
Currently translated at 100.0% (260 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/th/

powered by weblate
2026-10-06 11:04:53 +02:00
Nate Horst 98644db976 Translations: Update Thai
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Translate pretix user 594 814ae7f4d9 Translations: Update Russian
Currently translated at 44.6% (116 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/ru/

powered by weblate
2026-10-06 11:04:53 +02:00
Translate pretix user 594 ae39a1e112 Translations: Update Russian
Currently translated at 25.9% (1670 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Translate pretix user 594 61b94f1128 Translations: Update Russian
Currently translated at 41.9% (109 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/ru/

powered by weblate
2026-10-06 11:04:53 +02:00
Translate pretix user 594 dbb586ad5a Translations: Update Russian
Currently translated at 24.3% (1569 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Nate Horst 295b0443fb Translations: Update Thai
Currently translated at 99.9% (6441 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Nate Horst 31031225d3 Translations: Update Thai
Currently translated at 97.7% (6299 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Nate Horst d600115ea5 Translations: Update Thai
Currently translated at 95.2% (6134 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
CVZ-es 3d9ebb3072 Translations: Update Spanish
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
CVZ-es c9667e77c3 Translations: Update French
Currently translated at 100.0% (6442 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Nate Horst b2b797f4d3 Translations: Update Thai
Currently translated at 83.2% (5366 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Nate Horst 693adbc727 Translations: Update Thai
Currently translated at 81.3% (5242 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
szurofkamarciidfbe08444ef04788 8d9840be8d Translations: Update Hungarian
Currently translated at 98.7% (6364 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Raphael Michel 73c0c61b6d Translations: Update Swedish
Currently translated at 98.4% (256 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/sv/

powered by weblate
2026-10-06 11:04:53 +02:00
Raphael Michel a813269d1f Translations: Update Swedish
Currently translated at 85.4% (5503 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
Translate pretix user 594 d6bc1fa977 Translations: Update Russian
Currently translated at 38.8% (101 of 260 strings)

Translation: pretix/pretix (JavaScript parts)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/ru/

powered by weblate
2026-10-06 11:04:53 +02:00
Translate pretix user 594 249edfa2c4 Translations: Update Russian
Currently translated at 20.5% (1322 of 6442 strings)

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

powered by weblate
2026-10-06 11:04:53 +02:00
dependabot[bot] 0c28151d30 Update sentry-sdk requirement from ==2.70.* to ==2.71.* (#6632)
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.70.0...2.71.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.71.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-10-06 10:59:40 +02:00
pajowu 5bb3b7e35e Fix help_text showing as 'None' in orderimport&permissions (#6631) 2026-10-06 10:12:27 +02:00
Richard Schreiber 4119d04fba Add border to and increase text-contrast on disabled button (#6622)
* Add border to and increase text-contrast on disabled button

* Do not add border to disabled btn-link

* Add background to disabled button to further distinguish
2026-10-02 09:15:05 +02:00
84858bc048 Allow mails to be sent to addon-attendees (Z#23213551) (#6235)
Remove restrictions that prevent mails to be sent to addon-product-attendees while reducing amount of mails sent to the same email-addresses

Co-authored-by: Richard Schreiber <schreiber@rami.io>
Co-authored-by: Kara Engelhardt <engelhardt@pretix.eu>
2026-10-01 13:13:55 +02:00
Raphael Michel e8c091741b Invoice export: Fix crash PRETIXEU-FGE 2026-10-01 11:43:36 +02:00
Richard Schreiber ebdfd21b0f Fix JavaScript error description passing HTML (#6630) 2026-10-01 11:28:36 +02:00
pajowuandRichard Schreiber ed0b3cab7f Fix addon legend overlapping with profile checkbox (Z#23247075) (#6568)
* Fix addon legend overlapping with profile checkbox (Z#23247075)

* Update src/pretix/static/pretixpresale/scss/_checkout.scss

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-10-01 09:49:23 +02:00
Richard Schreiber 3dd8cc5862 PDF Editor: select newly created element after insert (#6596)
* PDF Editor: select newly created element after insert

* Do not create so many savepoints
2026-09-30 12:19:03 +02:00
dependabot[bot] 23e9ca8f34 Bump brace-expansion (#6625)
Bumps  and [brace-expansion](https://github.com/juliangruber/brace-expansion). These dependencies needed to be updated together.

Updates `brace-expansion` from 5.0.4 to 5.0.12
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.12)

Updates `brace-expansion` from 2.0.2 to 2.1.7
- [Release notes](https://github.com/juliangruber/brace-expansion/releases)
- [Commits](https://github.com/juliangruber/brace-expansion/compare/v5.0.4...v5.0.12)

---
updated-dependencies:
- dependency-name: brace-expansion
  dependency-version: 5.0.12
  dependency-type: indirect
- dependency-name: brace-expansion
  dependency-version: 2.1.7
  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-30 10:53:41 +02:00
Raphael Michel e19afd39fb Bump version to 2026.9.0.dev0 2026-09-30 10:50:09 +02:00
128 changed files with 9697 additions and 2609 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'
+1 -1
View File
@@ -173,7 +173,7 @@ Datetime String in ISO 8601 format ``"2017-12-27T10:00:00Z"``
with timezone (normally UTC) ``"2017-12-27T10:00:00.596934Z"``,
``"2017-12-27T10:00:00+02:00"``
Date String in ISO 8601 format ``2017-12-27``
Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de_Informal": "rot"}``
Multi-lingual string Object of strings ``{"en": "red", "de": "rot", "de-informal": "rot"}``
Money String with decimal number ``"23.42"``
Currency String with ISO 4217 code ``"EUR"``, ``"USD"``
Relative datetime *either* String in ISO 8601 ``"2017-12-27T10:00:00.596934Z"``,
+2 -1
View File
@@ -11,7 +11,8 @@ Core
----
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
:members: periodic_task, event_live_issues, event_copy_data, email_filter,
register_notification_types, event_notification_sent, organizer_notification_sent,
item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display,
register_text_placeholders, register_mail_placeholders, device_info_updated,
+1 -1
View File
@@ -1,6 +1,6 @@
sphinx==9.1.*
sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=2.0.0
sphinxcontrib-httpdomain~=2.1.0
sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2
+1 -1
View File
@@ -1,7 +1,7 @@
-e ../
sphinx==9.1.*
sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=2.0.0
sphinxcontrib-httpdomain~=2.1.0
sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2
+106 -108
View File
@@ -40,30 +40,30 @@
"license": "MIT"
},
"node_modules/@babel/helper-string-parser": {
"version": "7.27.1",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz",
"integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/helper-validator-identifier": {
"version": "7.28.5",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
"version": "7.29.7",
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
"license": "MIT",
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/@babel/parser": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
"version": "7.29.9",
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.9.tgz",
"integrity": "sha512-CjXrNHTnvqBVqHgdBysY3vk2T8tpJHb5/RMeHJBTyVa9xgugCB0CJTx/3oO8RV2QRQP391RWpB7D6hLjm8V9uA==",
"license": "MIT",
"dependencies": {
"@babel/types": "^7.29.0"
"@babel/types": "^7.29.8"
},
"bin": {
"parser": "bin/babel-parser.js"
@@ -73,13 +73,13 @@
}
},
"node_modules/@babel/types": {
"version": "7.29.0",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
"version": "7.29.8",
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.8.tgz",
"integrity": "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg==",
"license": "MIT",
"dependencies": {
"@babel/helper-string-parser": "^7.27.1",
"@babel/helper-validator-identifier": "^7.28.5"
"@babel/helper-string-parser": "^7.29.7",
"@babel/helper-validator-identifier": "^7.29.7"
},
"engines": {
"node": ">=6.9.0"
@@ -194,16 +194,16 @@
}
},
"node_modules/@eslint/config-array/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@eslint/config-array/node_modules/minimatch": {
@@ -378,9 +378,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"license": "MIT"
},
"node_modules/@napi-rs/wasm-runtime": {
@@ -1339,16 +1339,16 @@
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": {
@@ -1447,53 +1447,53 @@
"license": "MIT"
},
"node_modules/@vue/compiler-core": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.30.tgz",
"integrity": "sha512-s3DfdZkcu/qExZ+td75015ljzHc6vE+30cFMGRPROYjqkroYI5NV2X1yAMX9UeyBNWB9MxCfPcsjpLS11nzkkw==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-core/-/compiler-core-3.5.43.tgz",
"integrity": "sha512-zdiLhnbe1QQqgDT8xZMpNmyqZ3qlI+/Q/FHQco57Kwl/b05HhCzN6eVGN9QU9rbga4CrS0H5SYY8VGHZCt/1Hg==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/shared": "3.5.30",
"@babel/parser": "^7.29.8",
"@vue/shared": "3.5.43",
"entities": "^7.0.1",
"estree-walker": "^2.0.2",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-dom": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.30.tgz",
"integrity": "sha512-eCFYESUEVYHhiMuK4SQTldO3RYxyMR/UQL4KdGD1Yrkfdx4m/HYuZ9jSfPdA+nWJY34VWndiYdW/wZXyiPEB9g==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-dom/-/compiler-dom-3.5.43.tgz",
"integrity": "sha512-PEZoAk3NQmsn/ejMzSOCyTYqwGqczrWm70PuhBKjjv1+TCoQAaO/zOqNwjV+honlNstT5ILxtc+8r8UUfj+iEQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-core": "3.5.30",
"@vue/shared": "3.5.30"
"@vue/compiler-core": "3.5.43",
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/compiler-sfc": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.30.tgz",
"integrity": "sha512-LqmFPDn89dtU9vI3wHJnwaV6GfTRD87AjWpTWpyrdVOObVtjIuSeZr181z5C4PmVx/V3j2p+0f7edFKGRMpQ5A==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-sfc/-/compiler-sfc-3.5.43.tgz",
"integrity": "sha512-FCbrG3XNCRl+js3huuKx4IVHBLTvMkJhVepjbxSPu1gn4yWLaYtGNQdjJGZaMytXB6qb76qQDDmDSLy/vkmleQ==",
"license": "MIT",
"dependencies": {
"@babel/parser": "^7.29.0",
"@vue/compiler-core": "3.5.30",
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30",
"@babel/parser": "^7.29.8",
"@vue/compiler-core": "3.5.43",
"@vue/compiler-dom": "3.5.43",
"@vue/compiler-ssr": "3.5.43",
"@vue/shared": "3.5.43",
"estree-walker": "^2.0.2",
"magic-string": "^0.30.21",
"postcss": "^8.5.8",
"postcss": "^8.5.28",
"source-map-js": "^1.2.1"
}
},
"node_modules/@vue/compiler-ssr": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.30.tgz",
"integrity": "sha512-NsYK6OMTnx109PSL2IAyf62JP6EUdk4Dmj6AkWcJGBvN0dQoMYtVekAmdqgTtWQgEJo+Okstbf/1p7qZr5H+bA==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/compiler-ssr/-/compiler-ssr-3.5.43.tgz",
"integrity": "sha512-GF62orf7KiJX9RqrHNGrYBudsQGD0OhJ5nDs90O8UiDuS40+YMYomiXu6w6EuvtXuRDc3MSNis3EaaSKAVSWpg==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/shared": "3.5.30"
"@vue/compiler-dom": "3.5.43",
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/eslint-config-typescript": {
@@ -1537,53 +1537,51 @@
}
},
"node_modules/@vue/reactivity": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.30.tgz",
"integrity": "sha512-179YNgKATuwj9gB+66snskRDOitDiuOZqkYia7mHKJaidOMo/WJxHKF8DuGc4V4XbYTJANlfEKb0yxTQotnx4Q==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/reactivity/-/reactivity-3.5.43.tgz",
"integrity": "sha512-G/c9GyOZNI2jVaaS6OX1EF1SSFSv7H0ERqNTl4+DTFMlZmB5eVAB53aLQNam/7NL2NPtaDD7RdVrzf8uJzMuOA==",
"license": "MIT",
"dependencies": {
"@vue/shared": "3.5.30"
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/runtime-core": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.30.tgz",
"integrity": "sha512-e0Z+8PQsUTdwV8TtEsLzUM7SzC7lQwYKePydb7K2ZnmS6jjND+WJXkmmfh/swYzRyfP1EY3fpdesyYoymCzYfg==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/runtime-core/-/runtime-core-3.5.43.tgz",
"integrity": "sha512-hU6U6VnVhBGQDpvlnnDlIB8ZGJBiOcgk2lh/0InltHiz3D8oSkluvuvY+do1G2H3+udeKFsmaBlgVYP7gXQzEw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.30",
"@vue/shared": "3.5.30"
"@vue/reactivity": "3.5.43",
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/runtime-dom": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.30.tgz",
"integrity": "sha512-2UIGakjU4WSQ0T4iwDEW0W7vQj6n7AFn7taqZ9Cvm0Q/RA2FFOziLESrDL4GmtI1wV3jXg5nMoJSYO66egDUBw==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/runtime-dom/-/runtime-dom-3.5.43.tgz",
"integrity": "sha512-Bb2Jc0YjjJdMt1SJmb9b2L/IWd3I8lIT9x9eS/xvvP9CiVgna0ffua74xKRmt4/uSJ+0r4iN8ex1jrqkhQGWQw==",
"license": "MIT",
"dependencies": {
"@vue/reactivity": "3.5.30",
"@vue/runtime-core": "3.5.30",
"@vue/shared": "3.5.30",
"@vue/reactivity": "3.5.43",
"@vue/runtime-core": "3.5.43",
"@vue/shared": "3.5.43",
"csstype": "^3.2.3"
}
},
"node_modules/@vue/server-renderer": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.30.tgz",
"integrity": "sha512-v+R34icapydRwbZRD0sXwtHqrQJv38JuMB4JxbOxd8NEpGLny7cncMp53W9UH/zo4j8eDHjQ1dEJXwzFQknjtQ==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/server-renderer/-/server-renderer-3.5.43.tgz",
"integrity": "sha512-l2Ygjv9NehV94PSBxNWsAHC0j/eIIKbn92mBuWXAPGLnn6HfJ6MH5ubsd+Nk0YoZ5FRuxWI1P2VSoh+dbPQhCQ==",
"license": "MIT",
"dependencies": {
"@vue/compiler-ssr": "3.5.30",
"@vue/shared": "3.5.30"
},
"peerDependencies": {
"vue": "3.5.30"
"@vue/compiler-ssr": "3.5.43",
"@vue/runtime-dom": "3.5.43",
"@vue/shared": "3.5.43"
}
},
"node_modules/@vue/shared": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.30.tgz",
"integrity": "sha512-YXgQ7JjaO18NeK2K9VTbDHaFy62WrObMa6XERNfNOkAhD1F1oDSf3ZJ7K6GqabZ0BvSDHajp8qfS5Sa2I9n8uQ==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/@vue/shared/-/shared-3.5.43.tgz",
"integrity": "sha512-uksS7YGMR5NZyr4JNq0Rp+QyLns0ueaz20KwzIPW9R0LH1Vnt4E+XUM29PNseEbf1www2gOuhuDi5AKOIXag9Q==",
"license": "MIT"
},
"node_modules/acorn": {
@@ -1697,9 +1695,9 @@
"license": "ISC"
},
"node_modules/brace-expansion": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz",
"integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==",
"version": "2.1.7",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.7.tgz",
"integrity": "sha512-uZbew1NqdmPDTMJ8ah1y+b+9QEJrfkXFk3RcTQw3X0jW/xRUvFKsg1CfQdSYGdTbXZWExtU3J3ccxtnfw1Fi0g==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -2122,16 +2120,16 @@
}
},
"node_modules/eslint/node_modules/brace-expansion": {
"version": "5.0.4",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.4.tgz",
"integrity": "sha512-h+DEnpVvxmfVefa4jFbCf5HdH5YMDXRsmKflpf1pILZWRFlTbJpxeU55nJl4Smt5HQaGzg1o6RHFPJaOqnmBDg==",
"version": "5.0.12",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz",
"integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"balanced-match": "^4.0.2"
},
"engines": {
"node": "18 || 20 || >=22"
"node": "20 || >=22"
}
},
"node_modules/eslint/node_modules/eslint-visitor-keys": {
@@ -3190,9 +3188,9 @@
"license": "MIT"
},
"node_modules/nanoid": {
"version": "3.3.16",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
@@ -3366,9 +3364,9 @@
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"funding": [
{
"type": "opencollective",
@@ -3385,18 +3383,18 @@
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"nanoid": "^3.3.19",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/postcss-selector-parser": {
"version": "7.1.5",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.5.tgz",
"integrity": "sha512-KvvtD7SrlBP7dlgkBghEE3r84CABm5SmV2aNcG4oCA+qDnJ/tvKonFVvwWAyyWUEwxuNawdfEAZKP9zM3oZ2Uw==",
"version": "7.1.6",
"resolved": "https://registry.npmjs.org/postcss-selector-parser/-/postcss-selector-parser-7.1.6.tgz",
"integrity": "sha512-7qASPzhKF2l2KLboRZux8CCTRMdGiV08vWmyKzPz22qZ7ZjQBOeY7rNzNoCLSUiftJ7HUq0GERHmxw/t0dCdMw==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -4162,9 +4160,9 @@
}
},
"node_modules/smol-toml": {
"version": "1.7.1",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.7.1.tgz",
"integrity": "sha512-PPlsspAZ4jbMBu5DMFhfUGDQLu/vrL4SyBROVS37x8ynnVmFIs1VPBz1Co8Xks3TvpIaZXmU85y4DrQ+UyVFoQ==",
"version": "1.9.0",
"resolved": "https://registry.npmjs.org/smol-toml/-/smol-toml-1.9.0.tgz",
"integrity": "sha512-hpd+HLON7HdZXqYchMM/+LaTTbdK0AU3NngIJ4KVyWbY9bfQqdL9cD+4yf6dUoU2Ap4VsU0JkQi6FxAI1B2mXQ==",
"dev": true,
"license": "BSD-3-Clause",
"engines": {
@@ -4185,9 +4183,9 @@
}
},
"node_modules/source-map-js": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
@@ -4592,16 +4590,16 @@
"license": "MIT"
},
"node_modules/vue": {
"version": "3.5.30",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.30.tgz",
"integrity": "sha512-hTHLc6VNZyzzEH/l7PFGjpcTvUgiaPK5mdLkbjrTeWSRcEfxFrv56g/XckIYlE9ckuobsdwqd5mk2g1sBkMewg==",
"version": "3.5.43",
"resolved": "https://registry.npmjs.org/vue/-/vue-3.5.43.tgz",
"integrity": "sha512-o5qZoksdnjIKvW1srZ3ab7pcDNYAerBjRe54D0LBLfRdCYFrSgBHVXokMas35czQc0//lmx4/tuY4ZNQ+Rf2Ng==",
"license": "MIT",
"dependencies": {
"@vue/compiler-dom": "3.5.30",
"@vue/compiler-sfc": "3.5.30",
"@vue/runtime-dom": "3.5.30",
"@vue/server-renderer": "3.5.30",
"@vue/shared": "3.5.30"
"@vue/compiler-dom": "3.5.43",
"@vue/compiler-sfc": "3.5.43",
"@vue/runtime-dom": "3.5.43",
"@vue/server-renderer": "3.5.43",
"@vue/shared": "3.5.43"
},
"peerDependencies": {
"typescript": "*"
+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": {
+8 -8
View File
@@ -33,15 +33,15 @@ dependencies = [
"bleach==6.4.*",
"celery==5.6.*",
"chardet==5.2.*",
"cryptography>=50.0.1",
"css-inline==0.21.*",
"cryptography>=50.0.2",
"css-inline==0.22.*",
"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.1.*",
"django-filter==26.1",
"django-filter==26.2",
"django-formset-js-improved==0.5.0.5",
"django-formtools==2.7",
"django-hierarkey==2.0.*,>=2.0.2",
@@ -83,7 +83,7 @@ dependencies = [
"psycopg2-binary",
"pycountry",
"pycparser==3.0",
"pycryptodome==3.23.*",
"pycryptodome==3.24.*",
"pypdf==6.19.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab
"python-dateutil==2.9.*",
@@ -94,11 +94,11 @@ dependencies = [
"redis==7.4.*",
"reportlab==5.0.*",
"requests==2.34.*",
"sentry-sdk==2.70.*",
"sentry-sdk==2.71.*",
"sepaxml==2.7.*",
"stripe==7.9.*",
"text-unidecode==1.*",
"tlds>=2026072401",
"tlds>=2026093003",
"tqdm==4.*",
"ua-parser==1.0.*",
"vobject==0.9.*",
@@ -112,7 +112,7 @@ dev = [
"aiohttp==3.14.*",
"coverage",
"coveralls",
"fakeredis==2.38.*",
"fakeredis==2.39.*",
"flake8==7.4.*",
"freezegun",
"isort==9.0.*",
@@ -122,7 +122,7 @@ dev = [
"pytest-cache",
"pytest-cov",
"pytest-django==4.*",
"pytest-mock==3.15.*",
"pytest-mock==3.16.*",
"pytest-sugar",
"pytest-xdist==3.8.*",
"pytest-playwright",
+1
View File
@@ -31,6 +31,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 -1
View File
@@ -19,4 +19,4 @@
# 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/>.
#
__version__ = "2026.8.0"
__version__ = "2026.9.0.dev0"
+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',
+33
View File
@@ -0,0 +1,33 @@
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
from pretix.api.auth.utils import get_session_key_for_api_auth
def handle_file_upload(data, user, auth, allowed_types):
try:
cf = CachedFile.objects.get(
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
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
+3 -1
View File
@@ -261,7 +261,9 @@ class ItemAddOnSerializer(serializers.ModelSerializer):
def validate(self, data):
data = super().validate(data)
ItemAddOn.clean_max_min_count(data.get('max_count'), data.get('min_count'))
full_data = self.to_internal_value(self.to_representation(self.instance)) if self.instance else {}
full_data.update(data)
ItemAddOn.clean_max_min_count(full_data.get('max_count'), full_data.get('min_count'))
return data
+8 -31
View File
@@ -64,7 +64,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
@@ -76,6 +76,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):
@@ -329,35 +330,6 @@ with scopes_disabled():
)
def _handle_file_upload(data, user, auth):
try:
cf = CachedFile.objects.get(
file__isnull=False,
pk=data[len("file:"):],
)
if cf.session_key == "api-upload-<class 'django.contrib.auth.models.AnonymousUser'>-None":
# OK, backwards-compatibility of a security bug fixed 2026-09, delete this at some point, but should
# also be harmless because all files with this key are expired one day after deployment of this fix
# and no new files with this key are created
pass
elif cf.session_key != get_session_key_for_api_auth(user, auth):
raise ValidationError('The submitted file ID "{fid}" was not found.'.format(fid=data))
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):
list_by_event = {cl.event_id: cl for cl in checkinlists}
if not checkinlists:
@@ -802,7 +774,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:
+1 -1
View File
@@ -1683,7 +1683,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
@action(detail=True, methods=['POST'])
def confirm(self, request, **kwargs):
payment = self.get_object()
force = request.data.get('force', False)
force = request.data.get('force', False) if request.data else False
send_mail = request.data.get('send_email', True) if request.data else True
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
+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)
+2 -2
View File
@@ -71,8 +71,8 @@ class EmailTransmissionType(TransmissionType):
def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return {
"transmission_email_other": bool(transmission_info.get("transmission_email_address")),
"transmission_email_address": transmission_info.get("transmission_email_address"),
"transmission_email_other": bool((transmission_info or {}).get("transmission_email_address")),
"transmission_email_address": (transmission_info or {}).get("transmission_email_address"),
}
def form_data_to_transmission_info(self, form_data: dict) -> dict:
+20 -1
View File
@@ -42,7 +42,7 @@ from django.conf import settings
from django.core.mail import get_connection
from django.core.validators import MinLengthValidator, RegexValidator
from django.db import models
from django.db.models import Q
from django.db.models import Exists, OuterRef, Q
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
@@ -312,6 +312,25 @@ class Organizer(LoggedModel):
)
i += 1
def get_users_with_permission(self, permission):
"""
Returns a queryset of users who have a specific permission to this organizer.
:return: Iterable of User
"""
from .auth import User
if permission:
qs = Team.objects.with_organizer_permission(permission)
else:
qs = Team.objects.all()
team_with_perm = qs.filter(
members__pk=OuterRef('pk'),
organizer=self,
)
return User.objects.filter(Exists(team_with_perm))
def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
+34 -11
View File
@@ -35,12 +35,13 @@
import logging
from collections import OrderedDict, namedtuple
from itertools import groupby
from typing import Optional, Union
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import Event, LogEntry
from pretix.base.models import Event, LogEntry, Organizer
from pretix.base.signals import register_notification_types
from pretix.base.templatetags.money import money_filter
from pretix.helpers.urls import mainreverse_absolute
@@ -57,7 +58,8 @@ class Notification:
"""
Represents a notification that is sent/shown to a user. A notification consists of:
* one ``event`` reference
* one ``event`` reference (can be ``Ǹone``)
* one ``organizer`` reference
* one ``title`` text that is shown e.g. in the email subject or in a headline
* optionally one ``detail`` text that may or may not be shown depending on the notification method
* optionally one ``url`` that should be absolute and point to the context of an notification (e.g. an order)
@@ -67,8 +69,12 @@ class Notification:
each consisting of a button label and an absolute URL to point to.
"""
def __init__(self, event: Event, title: str, detail: str=None, url: str=None):
def __init__(self, event: Optional[Event], title: str, detail: Optional[str]=None, url: Optional[str]=None, organizer: Optional[Organizer]=None):
assert event or organizer, "You need to pass either event or organizer"
if event and organizer:
assert event.organizer_id == organizer.id, "Organizer and event mismatch"
self.title = title
self.organizer = organizer or event.organizer
self.event = event
self.detail = detail
self.url = url
@@ -91,8 +97,16 @@ class Notification:
class NotificationType:
def __init__(self, event: Event = None):
self.event = event
def __init__(self, event_or_organizer: Union[Event, Organizer] = None):
if isinstance(event_or_organizer, Event):
self.event = event_or_organizer
self.organizer = event_or_organizer.organizer
elif isinstance(event_or_organizer, Organizer):
self.event = None
self.organizer = event_or_organizer
else:
self.event = None
self.organizer = None
def __repr__(self):
return '<NotificationType: {}>'.format(self.action_type)
@@ -121,31 +135,39 @@ class NotificationType:
"""
raise NotImplementedError() # NOQA
@property
def is_event_level(self) -> bool:
"""
Return `True` if this notification type can be configured per event (the default).
"""
return True
def build_notification(self, logentry: LogEntry) -> Notification:
"""
This is the main function that you should override. It is supposed to turn a log entry
object into a notification object that can then be rendered e.g. into an email.
"""
return Notification(
logentry.event,
logentry.display()
event=logentry.event,
title=logentry.display(),
organizer=logentry.organizer,
)
def get_all_notification_types(event=None):
def get_all_notification_types(event_or_organizer=None):
global _ALL_TYPES
if event is None and _ALL_TYPES:
if event_or_organizer is None and _ALL_TYPES:
return _ALL_TYPES
types = OrderedDict()
for recv, ret in register_notification_types.send(event):
for recv, ret in register_notification_types.send(event_or_organizer):
if isinstance(ret, (list, tuple)):
for r in ret:
types[r.action_type] = r
else:
types[ret.action_type] = ret
if event is None:
if event_or_organizer is None:
_ALL_TYPES = types
return types
@@ -181,6 +203,7 @@ class ParametrizedOrderNotificationType(NotificationType):
n = Notification(
event=logentry.event,
organizer=logentry.organizer,
title=self._title.format(order=order, event=logentry.event),
url=order_url
)
+37 -18
View File
@@ -34,7 +34,9 @@ from pretix.base.models import (
from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import notification
from pretix.base.signals import (
event_notification_sent, organizer_notification_sent,
)
from pretix.celery_app import app
from pretix.helpers.celery import get_task_priority
from pretix.helpers.urls import mainreverse_absolute
@@ -47,40 +49,47 @@ def notify(logentry_ids: list):
logentry_ids = [logentry_ids]
qs = LogEntry.all.select_related(
'event', 'event__organizer'
'event', 'event__organizer', 'organizer'
).order_by(
'action_type', 'event_id',
'action_type', 'event_id', 'organizer_id',
).filter(id__in=logentry_ids)
_event, _at, notify_specific, notify_global = None, None, None, None
_event, _organizer, _at, notify_specific, notify_global = None, None, None, None, None
for logentry in qs:
if not logentry.event:
break # Ignore, we only have event-related notifications right now
if not logentry.event and not logentry.organizer:
break # Ignore, we only have event- or organizer-related notifications right now
notification_type = logentry.notification_type
if not notification_type:
break # No suitable plugin
if _event != logentry.event or _at != logentry.action_type or notify_global is None:
if _event != logentry.event or _organizer != logentry.organizer or _at != logentry.action_type or notify_global is None:
_event = logentry.event
_organizer = logentry.organizer
_at = logentry.action_type
event_or_organizer = logentry.event or logentry.organizer
# All users that have the permission to get the notification
users = logentry.event.get_users_with_permission(
users = event_or_organizer.get_users_with_permission(
notification_type.required_permission
).filter(notifications_send=True, is_active=True)
if logentry.user:
users = users.exclude(pk=logentry.user.pk)
# Get all notification settings, both specific to this event as well as global
notify_specific = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
if logentry.event:
notify_specific = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
event=logentry.event,
action_type=notification_type.action_type,
user__pk__in=users.values_list('pk', flat=True)
)
}
else:
notify_specific = {}
notify_global = {
(ns.user, ns.method): ns.enabled
for ns in NotificationSetting.objects.filter(
@@ -106,7 +115,10 @@ def notify(logentry_ids: list):
priority=get_task_priority("notifications", logentry.organizer_id),
)
notification.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
if logentry.event:
event_notification_sent.send(logentry.event, logentry_id=logentry.id, notification_type=notification_type.action_type)
elif logentry.organizer:
organizer_notification_sent.send(logentry.organizer, logentry_id=logentry.id, notification_type=notification_type.action_type)
@app.task(base=ProfiledTask, acks_late=True, max_retries=9, default_retry_delay=900)
@@ -158,13 +170,20 @@ def send_notification_mail(notification: Notification, user: User):
body_plain = tpl_plain.render(ctx)
guid = uuid.uuid4()
settings_holder = notification.event or notification.organizer
prefix = settings_holder.settings.mail_prefix
if not prefix:
if notification.event:
prefix = notification.event.slug.upper()
elif notification.organizer:
prefix = notification.organizer.name
m = OutgoingMail.objects.create(
guid=guid,
user=user,
to=[user.email],
subject='[{}] {}: {}'.format(
settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(),
prefix,
notification.title
),
body_plain=body_plain,
+35 -9
View File
@@ -2474,7 +2474,11 @@ class OrderChangeManager:
self.order.log_action('pretix.event.order.changed.feevalue', user=self.user, auth=self.auth, data={
'fee': fee.pk,
'old_price': fee.value,
'new_price': op.value.gross
'new_price': op.value.gross,
'old_tax_rate': fee.tax_rate,
'new_tax_rate': op.value.rate,
'old_tax_value': fee.tax_value,
'new_tax_value': op.value.tax,
})
fee.value = op.value.gross
fee._calculate_tax()
@@ -2484,9 +2488,13 @@ class OrderChangeManager:
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
'position': position.pk,
'positionid': position.positionid,
'old_price': position.price,
'addon_to': position.addon_to_id,
'new_price': op.price.gross
'old_price': position.price,
'new_price': op.price.gross,
'old_tax_rate': position.tax_rate,
'new_tax_rate': op.price.rate,
'old_tax_value': position.tax_value,
'new_tax_value': op.price.tax,
})
position.price = op.price.gross
position.price_includes_rounding_correction = Decimal("0.00")
@@ -2501,25 +2509,43 @@ class OrderChangeManager:
elif isinstance(op, self.TaxRuleOperation):
if isinstance(op.position, OrderPosition):
position = position_cache.setdefault(op.position.pk, op.position)
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
log = {
'position': position.pk,
'positionid': position.positionid,
'addon_to': position.addon_to_id,
'old_taxrule': position.tax_rule.pk if position.tax_rule else None,
'new_taxrule': op.tax_rule.pk
})
'new_taxrule': op.tax_rule.pk,
'old_price': position.price,
'old_tax_rate': position.tax_rate,
'old_tax_value': position.tax_value,
}
position._calculate_tax(op.tax_rule)
position.save()
log.update({
'new_price': position.price,
'new_tax_rate': position.tax_rate,
'new_tax_value': position.tax_value,
})
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data=log)
elif isinstance(op.position, OrderFee):
fee = fee_cache.setdefault(op.position.pk, op.position)
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data={
log = {
'fee': fee.pk,
'fee_type': fee.fee_type,
'old_taxrule': fee.tax_rule.pk if fee.tax_rule else None,
'new_taxrule': op.tax_rule.pk
})
'new_taxrule': op.tax_rule.pk,
'old_price': fee.value,
'old_tax_rate': fee.tax_rate,
'old_tax_value': fee.tax_value,
}
fee._calculate_tax(op.tax_rule)
fee.save()
log.update({
'new_price': fee.value,
'new_tax_rate': fee.tax_rate,
'new_tax_value': fee.tax_value,
})
self.order.log_action('pretix.event.order.changed.tax_rule', user=self.user, auth=self.auth, data=log)
elif isinstance(op, self.CancelFeeOperation):
fee = fee_cache.setdefault(op.fee.pk, op.fee)
self.order.log_action('pretix.event.order.changed.cancelfee', user=self.user, auth=self.auth, data={
+85 -41
View File
@@ -21,17 +21,21 @@
#
import logging
import os
import uuid
from datetime import datetime, timezone
from decimal import Decimal
from django.core.files.base import ContentFile
from django.db import transaction
from django.utils.timezone import now
from django.utils.translation import gettext as _
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
OrderPosition,
CachedCombinedTicket, CachedFile, CachedTicket, Event, InvoiceAddress,
Order, OrderPosition,
)
from pretix.base.services.tasks import EventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -42,7 +46,7 @@ from pretix.helpers.database import rolledback_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 +56,23 @@ 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
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):
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 +82,88 @@ 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 preview(event: int, provider: str):
def get_preview_position(event):
connection = transaction.get_connection()
if not connection.in_atomic_block:
raise RuntimeError("get_preview_position needs to be called in a rolledback_transaction")
item = event.items.create(name=LazyI18nString.from_gettext("Sample product"), default_price=Decimal('42.23'),
description=LazyI18nString.from_gettext("Sample product description"))
item2 = event.items.create(name=LazyI18nString.from_gettext("Sample workshop"), default_price=Decimal('23.40'))
item.program_times.create(item=item, start=datetime(2017, 12, 27, 0, 0, 0, tzinfo=timezone.utc),
end=datetime(2017, 12, 28, 0, 0, 0, tzinfo=timezone.utc))
from pretix.base.models import Order, SeatingPlan
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()}
SeatingPlan.objects.create(
name="Plan", organizer=event.organizer, layout="{}"
)
event.seat_category_mappings.create(
layout_category='Stalls', product=item
)
seat = event.seats.create(seat_number="1", row_label="C", product=item, seat_guid=uuid.uuid4())
position = order.positions.create(item=item, attendee_name_parts=sample, price=item.default_price, seat=seat)
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 +187,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:
+13 -5
View File
@@ -605,15 +605,14 @@ subclass of pretix.base.ticketoutput.BaseTicketOutput
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
register_notification_types = EventPluginSignal()
register_notification_types = GlobalSignal()
"""
This signal is sent out to get all known notification types. Receivers should return an
instance of a subclass of pretix.base.notifications.NotificationType or a list of such
instances.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event,
however for this signal, the ``sender`` **may also be None** to allow creating the general
notification settings!
When called for actually sending notifications, ``sender`` will be the event or organizer,
depending on context.
"""
register_event_permission_groups = GlobalSignal()
@@ -628,7 +627,7 @@ This signal is sent out to get all known permissions. Receivers should return an
instance of pretix.base.permissions.PermissionGroup or a list of such instances.
"""
notification = EventPluginSignal()
event_notification_sent = EventPluginSignal()
"""
Arguments: ``logentry_id``, ``notification_type``
@@ -637,6 +636,15 @@ This signal is sent out when a notification is sent.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
organizer_notification_sent = OrganizerPluginSignal()
"""
Arguments: ``logentry_id``, ``notification_type``
This signal is sent out when a notification is sent.
As with all organizer-plugin signals, the ``sender`` keyword argument will contain the organizer.
"""
register_sales_channel_types = GlobalSignal()
"""
This signal is sent out to get all known sales channels types. Receivers should return an
+2 -2
View File
@@ -36,10 +36,10 @@ register = template.Library()
@register.filter("money")
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False):
def money_filter(value: Optional[Decimal | float | int | str], arg='', hide_currency=False) -> str:
if isinstance(value, (float, int, str)):
if value == '':
return value
return value # pyright: ignore[reportReturnType]
value = Decimal(value)
if value is None:
value = Decimal('0.00')
+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:
"""
+1 -1
View File
@@ -1005,7 +1005,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):
+1 -1
View File
@@ -66,7 +66,7 @@ class ProcessForm(forms.Form):
widget=forms.Select(
attrs={'data-static': 'true'}
),
help_text=conditional_escape(c.help_text),
help_text=conditional_escape(c.help_text or ""),
)
def get_columns(self):
+2 -2
View File
@@ -364,7 +364,7 @@ class TeamForm(forms.ModelForm):
for opt in pg.options
],
label=pg.label,
help_text=conditional_escape(pg.help_text),
help_text=conditional_escape(pg.help_text or ""),
initial=initial,
widget=forms.RadioSelect,
)
@@ -389,7 +389,7 @@ class TeamForm(forms.ModelForm):
for opt in pg.options
],
label=pg.label,
help_text=conditional_escape(pg.help_text),
help_text=conditional_escape(pg.help_text or ""),
initial=initial,
widget=forms.RadioSelect,
)
@@ -481,5 +481,15 @@
<div class="steps">
</div>
{% 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 %}
</body>
</html>
@@ -10,6 +10,7 @@
{% load l10n %}
{% load phone_format %}
{% load getitem %}
{% load dialog %}
{% block title %}
{% blocktrans trimmed with code=order.code %}
Order details: {{ code }}
@@ -568,7 +569,8 @@
class="form-inline helper-display-inline{% if b.javascript_required %} requirejs{% endif %}">
{% csrf_token %}
<button type="submit"
class="btn btn-xs btn-default">
class="btn btn-xs btn-default"
{% if b.confirmation_text %}data-confirmation-text="{{ b.confirmation_text|rich_text|force_escape }}"{% endif %}>
<span class="fa {{ b.icon }}"></span> {{ b.text }}
</button>
</form>
+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
+34 -9
View File
@@ -54,6 +54,7 @@ from django.db.models import (
Count, Exists, F, IntegerField, Max, OuterRef, Prefetch, ProtectedError, Q,
QuerySet, Subquery, Sum,
)
from uuid import UUID
from django.forms import formset_factory
from django.http import (
FileResponse, Http404, HttpResponseNotAllowed, HttpResponseRedirect,
@@ -582,12 +583,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
@@ -667,7 +671,18 @@ class OrderDownload(AsyncAction, OrderView):
permission = 'event.orders:read'
def get_success_url(self, value):
return self.get_self_url()
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_error_url(self):
return self.get_order_url()
@@ -706,12 +721,19 @@ class OrderDownload(AsyncAction, OrderView):
if 'position' in kwargs and not self.order_position.generate_ticket:
return self.error(_('Ticket download is not enabled for this product.'))
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_message(self, value):
return ""
@@ -738,12 +760,15 @@ class OrderDownload(AsyncAction, OrderView):
content_type=value.type
)
else:
return redirect(self.get_self_url())
return redirect(self.get_success_url(value))
def get_last_ct(self):
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
if self.output.is_cacheable:
ct = CachedTicket.objects.filter(
order_position=self.order_position, provider=self.output.identifier, file__isnull=False
).last()
else:
ct = None
if not ct or not ct.file:
return None
return ct
+1
View File
@@ -761,6 +761,7 @@ class UserNotificationsEditView(TemplateView):
{k: a.get(t) for k, a in self.global_set.items()},
)
for t, tv in self.types.items()
if tv.is_event_level or not self.event
]
ctx['event'] = self.event
if self.event:
+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):
+49 -81
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-28 11:01+0000\n"
"Last-Translator: Tim <plicnetwork@gmail.com>\n"
"PO-Revision-Date: 2026-10-03 23:00+0000\n"
"Last-Translator: Oksigenia <github@oksigenia.cc>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix/"
"es/>\n"
"Language: es\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.9.1\n"
"X-Generator: Weblate 2026.10\n"
#: pretix/_base_settings.py
msgid "English"
@@ -1793,16 +1793,12 @@ msgid "Transmission type"
msgstr "Tipo de transmisión"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission status"
msgstr "Tipo de transmisión"
msgstr "Estado de la transmisión"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission date"
msgstr "Tipo de transmisión"
msgstr "Fecha de emisión"
#: pretix/base/exporters/invoices.py
msgid "Cancellation"
@@ -3289,7 +3285,7 @@ msgstr ""
#: pretix/base/forms/validators.py pretix/control/views/event.py
#, python-format
msgid "Invalid placeholder: {%(value)s}"
msgstr "Marcador de posición no válido: {%(value)ss}"
msgstr "Marcador de posición no válido: {%(value)s}"
#: pretix/base/forms/widgets.py
#, python-format
@@ -4458,10 +4454,8 @@ msgid "Initialization date"
msgstr "Fecha de inicialización"
#: pretix/base/models/devices.py
#, fuzzy
#| msgid "Save to profile"
msgid "Security profile"
msgstr "Guardar en perfil"
msgstr "Perfil de seguridad"
#: pretix/base/models/discount.py
msgctxt "subevent"
@@ -5904,10 +5898,8 @@ msgid "Maximum value"
msgstr "Valor máximo"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Maximum length"
msgid "Minimum length"
msgstr "Longitud máxima"
msgstr "Longitud mínima"
#: pretix/base/models/items.py
msgid "Maximum length"
@@ -5978,10 +5970,8 @@ msgid "The maximum value must not be lower than the minimum value."
msgstr "El valor máximo no debe ser inferior al mínimo."
#: pretix/base/models/items.py
#, fuzzy
#| msgid "The maximum value must not be lower than the minimum value."
msgid "The maximum length must not be shorter than the minimum length."
msgstr "El valor máximo no debe ser inferior al mínimo."
msgstr "La longitud máxima no debe ser inferior a la longitud mínima."
#: pretix/base/models/items.py
msgid ""
@@ -10581,10 +10571,8 @@ msgstr ""
"después de la `Última fecha de pagos' configurada anteriormente."
#: pretix/base/settings.py
#, fuzzy
#| msgid "Generate invoices for Sales channels"
msgid "Allow postponed payment choice for sales channels"
msgstr "Generar facturas para canales de ventas"
msgstr "Permitir la opción de pago aplazado en los canales de venta"
#: pretix/base/settings.py
msgid ""
@@ -10592,6 +10580,9 @@ msgid ""
"their order without selecting a payment method. This is useful whenever "
"orders are not created by the same person who is making the payment."
msgstr ""
"Si en un canal de venta se permite el pago aplazado, los clientes pueden "
"completar su pedido sin seleccionar una forma de pago. Esto resulta útil "
"cuando los pedidos no los realiza la misma persona que efectúa el pago."
#: pretix/base/settings.py
msgid "Show start date"
@@ -16860,7 +16851,7 @@ msgstr "tercero"
#: pretix/control/forms/rrule.py
msgctxt "rrule"
msgid "fourth"
msgstr ""
msgstr "cuarto"
#: pretix/control/forms/rrule.py
msgctxt "rrule"
@@ -17993,28 +17984,20 @@ msgid "Queued emails have been aborted."
msgstr "Los correos electrónicos en cola se han cancelado."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The property has been created."
msgid "An organizer meta property has been created."
msgstr "La propiedad ha sido creada."
msgstr "Se ha creado una metapropiedad de organizador."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The selected property has been deleted."
msgid "An organizer meta property has been deleted."
msgstr "La propiedad seleccionada ha sido eliminada."
msgstr "Se ha eliminado una metapropiedad del organizador."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The organizer has been changed."
msgid "An organizer meta property has been changed."
msgstr "El pedido se ha cambiado."
msgstr "Se ha modificado una metapropiedad del organizador."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The product has been reordered."
msgid "An organizer meta property has been reordered."
msgstr "El producto se ha pedido de nuevo."
msgstr "Se ha cambiado el orden de una metapropiedad del organizador."
#: pretix/control/logdisplay.py
msgid "Gift card acceptance for another organizer has been added."
@@ -18228,6 +18211,8 @@ msgstr "Se ha generado un código de emergencia de doble factor."
#: pretix/control/logdisplay.py
msgid "Drift and throttle values for two-factor devices have been reset."
msgstr ""
"Se han restablecido los valores de deriva y aceleración para los "
"dispositivos de dos factores."
#: pretix/control/logdisplay.py
#, python-brace-format
@@ -19180,10 +19165,8 @@ msgid "Cancel"
msgstr "Cancelar"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
#, fuzzy
#| msgid "Authorized apps"
msgid "Authorize"
msgstr "Aplicaciones autorizadas"
msgstr "Autorizar"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Error:"
@@ -22071,14 +22054,10 @@ msgstr ""
"hacer referencia a este evento."
#: pretix/control/templates/pretixcontrol/events/create_basics.html
#, fuzzy
#| msgid ""
#| "We strongly recommend against using short forms of more then 16 "
#| "characters."
msgid ""
"We strongly recommend against using short forms of more than 16 characters."
msgstr ""
"Recomendamos encarecidamente no utilizar formularios cortos de más de 16 "
"Recomendamos encarecidamente no utilizar abreviaturas de más de 16 "
"caracteres."
#: pretix/control/templates/pretixcontrol/events/create_basics.html
@@ -27957,27 +27936,23 @@ msgstr "Autenticación backend"
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Reset drift and throttle"
msgstr ""
msgstr "Restablecer la desviación y el control"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy
#| msgid "Disable two-factor authentication"
msgid "Available two-factor authentication methods"
msgstr "Desactivar la autenticación de dos factores"
msgstr "Métodos de autenticación de dos factores disponibles"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy, python-format
#| msgid "Expired since %(date)s"
#, python-format
msgid "1 failed attempt since %(date)s"
msgid_plural "%(cnt)s failed attempts since %(date)s"
msgstr[0] "Vencido desde el %(date)s"
msgstr[1] "Vencido desde el %(date)s"
msgstr[0] "1 intento fallido desde %(date)s"
msgstr[1] "%(cnt)s intentos fallidos desde el %(date)s"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy, python-format
#| msgid "Valid until %(date)s"
#, python-format
msgid "Currently locked until %(date)s"
msgstr "Válido hasta el %(date)s"
msgstr "Actualmente bloqueado hasta el %(date)s"
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Team memberships"
@@ -28519,20 +28494,18 @@ msgid "Invalid code, please try again."
msgstr "Código inválido, por favor inténtelo de nuevo."
#: pretix/control/views/auth.py
#, fuzzy, python-brace-format
#| msgid "Invalid code, please try again."
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} minute."
msgid_plural "Invalid code. Please try again after waiting {value} minutes."
msgstr[0] "Código inválido, por favor inténtelo de nuevo."
msgstr[1] "Código inválido, por favor inténtelo de nuevo."
msgstr[0] "Código no válido. Inténtalo de nuevo tras esperar {value} minutos."
msgstr[1] "ódigo no válido. Inténtalo de nuevo tras esperar {value} minutos."
#: pretix/control/views/auth.py
#, fuzzy, python-brace-format
#| msgid "Invalid code, please try again."
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} second."
msgid_plural "Invalid code. Please try again after waiting {value} seconds."
msgstr[0] "Código inválido, por favor inténtelo de nuevo."
msgstr[1] "Código inválido, por favor inténtelo de nuevo."
msgstr[0] "Código no válido. Inténtalo de nuevo tras esperar {value} segundo."
msgstr[1] "Código no válido. Inténtalo de nuevo tras esperar {value} segundos."
#: pretix/control/views/checkin.py
msgid "The selected check-ins have been reverted."
@@ -29405,10 +29378,8 @@ msgid "You entered an order in an event with a different currency."
msgstr "Ha introducido un pedido en un evento con una moneda diferente."
#: pretix/control/views/orders.py
#, fuzzy
#| msgid "You entered an order in an event with a different currency."
msgid "You entered an order in an event that you do not have access to."
msgstr "Ha introducido un pedido en un evento con una moneda diferente."
msgstr "Ha introducido un pedido en un evento al que no tienes acceso."
#: pretix/control/views/orders.py
msgid ""
@@ -30296,10 +30267,9 @@ msgstr ""
"código de emergencia anterior para este usuario permanecerá activo."
#: pretix/control/views/users.py
#, fuzzy
#| msgid "The password has been reset."
msgid "The drift values for TOTP devices have been reset."
msgstr "La contraseña ha sido reiniciada."
msgstr ""
"Se han restablecido los valores de desviación para los dispositivos TOTP."
#: pretix/control/views/users.py
msgid "The new user has been created."
@@ -30314,14 +30284,12 @@ msgid "Bypass quota"
msgstr "Cuota de bypass"
#: pretix/control/views/vouchers.py
#, fuzzy
#| msgid "Widget"
msgid "Budget"
msgstr "Widget"
msgstr "Presupuesto"
#: pretix/control/views/vouchers.py
msgid "Budget used"
msgstr ""
msgstr "Presupuesto utilizado"
#: pretix/control/views/vouchers.py
msgid "The requested voucher does not exist."
@@ -35022,29 +34990,29 @@ msgid ""
"Not sure yet? You can complete your order first and then select a payment "
"method later."
msgstr ""
"¿Aún no estás seguro? Puedes completar primero tu pedido y seleccionar la "
"forma de pago más tarde."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
msgid ""
"To do so, please first remove the payment methods you already selected above."
msgstr ""
"Para ello, elimina primero los métodos de pago que ya hayas seleccionado "
"anteriormente."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy, python-format
#| msgid "Your event needs to be over to use this feature."
#, python-format
msgid "Your payment needs to be completed within %(minutes)s minutes."
msgstr "Su evento debe haber terminado para utilizar esta función."
msgstr "Su pago debe completarse en un plazo de %(minutes)s minutos."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy, python-format
#| msgid "Your event needs to be over to use this feature."
#, python-format
msgid "Your payment needs to be completed by %(deadline)s."
msgstr "Su evento debe haber terminado para utilizar esta función."
msgstr "Su pago debe completarse antes de %(deadline)s."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy
#| msgid "Proceed with reset"
msgid "Proceed without selection"
msgstr "Continuar con el restablecimiento"
msgstr "Continuar sin seleccionar nada"
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
msgid "Before we continue, we need you to answer some questions."
+4 -4
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-06-29 17:00+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"PO-Revision-Date: 2026-10-03 23:00+0000\n"
"Last-Translator: Oksigenia <github@oksigenia.cc>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/es/>\n"
"Language: es\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.10\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -780,7 +780,7 @@ msgstr "El organizador retiene %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js
msgid "You get %(currency)s %(amount)s back"
msgstr "Se le devolverá %(moneda)s %(cantidad)s"
msgstr "Se le devolverá %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js
msgid "Please enter the amount the organizer can keep."
+51 -81
View File
@@ -4,10 +4,10 @@ msgstr ""
"Project-Id-Version: 1\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-30 07:25+0000\n"
"PO-Revision-Date: 2026-10-01 09:28+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/fr/"
">\n"
"Language-Team: French <https://translate.pretix.eu/projects/pretix/pretix/"
"fr/>\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
@@ -1793,16 +1793,12 @@ msgid "Transmission type"
msgstr "Type de transmission"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission status"
msgstr "Type de transmission"
msgstr "État de la transmission"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission date"
msgstr "Type de transmission"
msgstr "Date de transmission"
#: pretix/base/exporters/invoices.py
msgid "Cancellation"
@@ -4468,10 +4464,8 @@ msgid "Initialization date"
msgstr "Date d'initialisation"
#: pretix/base/models/devices.py
#, fuzzy
#| msgid "Save to profile"
msgid "Security profile"
msgstr "Enregistrer dans le profil"
msgstr "Profil de sécurité"
#: pretix/base/models/discount.py
msgctxt "subevent"
@@ -5925,10 +5919,8 @@ msgid "Maximum value"
msgstr "Valeur maximale"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Maximum length"
msgid "Minimum length"
msgstr "Longueur maximale"
msgstr "Longueur minimale"
#: pretix/base/models/items.py
msgid "Maximum length"
@@ -6000,10 +5992,9 @@ msgid "The maximum value must not be lower than the minimum value."
msgstr "La valeur maximale ne doit pas être inférieure à la valeur minimale."
#: pretix/base/models/items.py
#, fuzzy
#| msgid "The maximum value must not be lower than the minimum value."
msgid "The maximum length must not be shorter than the minimum length."
msgstr "La valeur maximale ne doit pas être inférieure à la valeur minimale."
msgstr ""
"La longueur maximale ne doit pas être inférieure à la longueur minimale."
#: pretix/base/models/items.py
msgid ""
@@ -10647,10 +10638,8 @@ msgstr ""
"accepté après la 'dernière date de paiement' configurée ci-dessus."
#: pretix/base/settings.py
#, fuzzy
#| msgid "Generate invoices for Sales channels"
msgid "Allow postponed payment choice for sales channels"
msgstr "Générer des factures pour les canaux de vente"
msgstr "Autoriser l'option de paiement différé pour les canaux de vente"
#: pretix/base/settings.py
msgid ""
@@ -10658,6 +10647,10 @@ msgid ""
"their order without selecting a payment method. This is useful whenever "
"orders are not created by the same person who is making the payment."
msgstr ""
"Si le paiement différé est autorisé sur un canal de vente, les clients "
"peuvent finaliser leur commande sans sélectionner de mode de paiement. Cette "
"fonctionnalité est utile lorsque la commande n'est pas passée par la même "
"personne que celle qui effectue le paiement."
#: pretix/base/settings.py
msgid "Show start date"
@@ -17017,7 +17010,7 @@ msgstr "troisième"
#: pretix/control/forms/rrule.py
msgctxt "rrule"
msgid "fourth"
msgstr ""
msgstr "quatrième"
#: pretix/control/forms/rrule.py
msgctxt "rrule"
@@ -18137,28 +18130,20 @@ msgid "Queued emails have been aborted."
msgstr "Les e-mails en attente ont été annulés."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The property has been created."
msgid "An organizer meta property has been created."
msgstr "La propriété a été créée."
msgstr "Une méta-propriété d'organisateur a été créée."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The selected property has been deleted."
msgid "An organizer meta property has been deleted."
msgstr "La propriété sélectionnée a été supprimée."
msgstr "Une méta-propriété d'organisateur a été supprimée."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The organizer has been changed."
msgid "An organizer meta property has been changed."
msgstr "L’organisateur a été modifié."
msgstr "Une méta-propriété d'organisateur a été modifiée."
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The product has been reordered."
msgid "An organizer meta property has been reordered."
msgstr "Le produit a été commandé à nouveau."
msgstr "Une méta-propriété d'organisateur a été réorganisée."
#: pretix/control/logdisplay.py
msgid "Gift card acceptance for another organizer has been added."
@@ -18374,6 +18359,8 @@ msgstr "Un code d'urgence à deux facteurs a été généré."
#: pretix/control/logdisplay.py
msgid "Drift and throttle values for two-factor devices have been reset."
msgstr ""
"Les valeurs de déviation et de puissance pour les appareils à deux facteurs "
"ont été réinitialisées."
#: pretix/control/logdisplay.py
#, python-brace-format
@@ -19322,10 +19309,8 @@ msgid "Cancel"
msgstr "Annuler"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
#, fuzzy
#| msgid "Authorized apps"
msgid "Authorize"
msgstr "App. autorisées"
msgstr "Autoriser"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Error:"
@@ -22231,15 +22216,11 @@ msgstr ""
"comme abréviations pour faire référence à cet événement."
#: pretix/control/templates/pretixcontrol/events/create_basics.html
#, fuzzy
#| msgid ""
#| "We strongly recommend against using short forms of more then 16 "
#| "characters."
msgid ""
"We strongly recommend against using short forms of more than 16 characters."
msgstr ""
"Nous vous déconseillons fortement d'utiliser des formulaires courts de plus "
"de 16 caractères."
"Nous déconseillons vivement d'utiliser des abréviations de plus de 16 "
"caractères."
#: pretix/control/templates/pretixcontrol/events/create_basics.html
msgid "Display settings"
@@ -28171,27 +28152,23 @@ msgstr "Backend d’authentification"
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Reset drift and throttle"
msgstr ""
msgstr "Réinitialisation de la déviation et de la puissance"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy
#| msgid "Disable two-factor authentication"
msgid "Available two-factor authentication methods"
msgstr "Désactiver l'authentification à deux facteurs"
msgstr "Méthodes d'authentification à deux facteurs disponibles"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy, python-format
#| msgid "Expired since %(date)s"
#, python-format
msgid "1 failed attempt since %(date)s"
msgid_plural "%(cnt)s failed attempts since %(date)s"
msgstr[0] "Expiré depuis le %(date)s"
msgstr[1] "Expiré depuis le %(date)s"
msgstr[0] "1 tentative infructueuse depuis %(date)s"
msgstr[1] "%(cnt)s tentatives infructueuses depuis le %(date)s"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy, python-format
#| msgid "Valid until %(date)s"
#, python-format
msgid "Currently locked until %(date)s"
msgstr "Valable jusqu'au %(date)s"
msgstr "Actuellement verrouillé jusqu'au %(date)s"
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Team memberships"
@@ -28737,20 +28714,19 @@ msgid "Invalid code, please try again."
msgstr "Code invalide, veuillez réessayer."
#: pretix/control/views/auth.py
#, fuzzy, python-brace-format
#| msgid "Invalid code, please try again."
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} minute."
msgid_plural "Invalid code. Please try again after waiting {value} minutes."
msgstr[0] "Code invalide, veuillez réessayer."
msgstr[1] "Code invalide, veuillez réessayer."
msgstr[0] "Code invalide. Veuillez réessayer après avoir patienté {value} minute."
msgstr[1] "Code invalide. Veuillez réessayer après avoir patienté {value} minutes."
#: pretix/control/views/auth.py
#, fuzzy, python-brace-format
#| msgid "Invalid code, please try again."
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} second."
msgid_plural "Invalid code. Please try again after waiting {value} seconds."
msgstr[0] "Code invalide, veuillez réessayer."
msgstr[1] "Code invalide, veuillez réessayer."
msgstr[0] "Code invalide. Veuillez réessayer après avoir patienté {value} seconde."
msgstr[1] ""
"Code invalide. Veuillez réessayer après avoir patienté {value} secondes."
#: pretix/control/views/checkin.py
msgid "The selected check-ins have been reverted."
@@ -29632,11 +29608,9 @@ msgstr ""
"Vous avez passé une commande dans un événement dont la devise est différente."
#: pretix/control/views/orders.py
#, fuzzy
#| msgid "You entered an order in an event with a different currency."
msgid "You entered an order in an event that you do not have access to."
msgstr ""
"Vous avez passé une commande dans un événement dont la devise est différente."
"Vous avez saisi une commande dans un événement auquel vous n'avez pas accès."
#: pretix/control/views/orders.py
msgid ""
@@ -30535,10 +30509,8 @@ msgstr ""
"d'urgence précédents de cet utilisateur sont toujours actifs."
#: pretix/control/views/users.py
#, fuzzy
#| msgid "The password has been reset."
msgid "The drift values for TOTP devices have been reset."
msgstr "Le mot de passe a été réinitialisé."
msgstr "Les valeurs de dérive des appareils TOTP ont été réinitialisées."
#: pretix/control/views/users.py
msgid "The new user has been created."
@@ -30553,14 +30525,12 @@ msgid "Bypass quota"
msgstr "Outrepasser le quota"
#: pretix/control/views/vouchers.py
#, fuzzy
#| msgid "Widget"
msgid "Budget"
msgstr "Widget"
msgstr "Budget"
#: pretix/control/views/vouchers.py
msgid "Budget used"
msgstr ""
msgstr "Budget utilisé"
#: pretix/control/views/vouchers.py
msgid "The requested voucher does not exist."
@@ -35309,29 +35279,29 @@ msgid ""
"Not sure yet? You can complete your order first and then select a payment "
"method later."
msgstr ""
"Vous hésitez encore ? Vous pouvez d'abord finaliser votre commande, puis "
"choisir un mode de paiement ultérieurement."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
msgid ""
"To do so, please first remove the payment methods you already selected above."
msgstr ""
"Pour ce faire, veuillez d'abord supprimer les moyens de paiement que vous "
"avez déjà sélectionnés ci-dessus."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy, python-format
#| msgid "The payment has to be completed before %(date)s."
#, python-format
msgid "Your payment needs to be completed within %(minutes)s minutes."
msgstr "Le paiement doit être effectué avant le %(date)s."
msgstr "otre paiement doit être effectué dans les %(minutes)s minutes."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy, python-format
#| msgid "The payment has to be completed before %(date)s."
#, python-format
msgid "Your payment needs to be completed by %(deadline)s."
msgstr "Le paiement doit être effectué avant le %(date)s."
msgstr "Votre paiement doit être effectué avant le %(deadline)s."
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy
#| msgid "Proceed with reset"
msgid "Proceed without selection"
msgstr "Procéder à la réinitialisation"
msgstr "Continuer sans sélectionner"
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
msgid "Before we continue, we need you to answer some questions."
+11 -33
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-08-01 21:00+0000\n"
"PO-Revision-Date: 2026-09-30 17:00+0000\n"
"Last-Translator: szurofkamarciidfbe08444ef04788 <szurofkamarcii@gmail.com>\n"
"Language-Team: Hungarian <https://translate.pretix.eu/projects/pretix/pretix/"
"hu/>\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.7.1\n"
"X-Generator: Weblate 2026.9.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -93,7 +93,7 @@ msgstr "Héber"
#: pretix/_base_settings.py
msgid "Hungarian"
msgstr ""
msgstr "Magyar"
#: pretix/_base_settings.py
msgid "Indonesian"
@@ -426,10 +426,8 @@ msgid "You cannot exchange a medium for a medium."
msgstr "Nem cserélhet adathordozót adathordozóra."
#: pretix/api/views/checkin.py
#, fuzzy
#| msgid "Product does not support medium exchange."
msgid "You cannot simulate a medium exchange."
msgstr "A termék nem támogatja az adathordozó-cserét."
msgstr "Az adathordozó szimuláció nem engedélyezett."
#: pretix/api/views/oauth.py pretix/control/logdisplay.py
#, python-brace-format
@@ -1779,16 +1777,12 @@ msgid "Transmission type"
msgstr "Továbbítás típusa"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission status"
msgstr "Továbbítás típusa"
msgstr "Továbbítás állapot"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission date"
msgstr "Továbbítás típusa"
msgstr "Továbbítás dátum"
#: pretix/base/exporters/invoices.py
msgid "Cancellation"
@@ -3078,9 +3072,6 @@ msgid "Street"
msgstr "Utca"
#: pretix/base/forms/questions.py
#, fuzzy
#| msgctxt "address"
#| msgid "State"
msgid "State"
msgstr "Állam/tartomány"
@@ -4429,10 +4420,8 @@ msgid "Initialization date"
msgstr "Inicializálás dátuma"
#: pretix/base/models/devices.py
#, fuzzy
#| msgid "Save to profile"
msgid "Security profile"
msgstr "Mentés a profilhoz"
msgstr "Biztonsági profil"
#: pretix/base/models/discount.py
msgctxt "subevent"
@@ -5864,10 +5853,8 @@ msgid "Maximum value"
msgstr "Maximális érték"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Maximum length"
msgid "Minimum length"
msgstr "Maximális hossz"
msgstr "Minimum hossz"
#: pretix/base/models/items.py
msgid "Maximum length"
@@ -5938,10 +5925,8 @@ msgid "The maximum value must not be lower than the minimum value."
msgstr "A maximális érték nem lehet kisebb a minimális értéknél."
#: pretix/base/models/items.py
#, fuzzy
#| msgid "The maximum value must not be lower than the minimum value."
msgid "The maximum length must not be shorter than the minimum length."
msgstr "A maximális érték nem lehet kisebb a minimális értéknél."
msgstr "A maximális hossz nem lehet rövidebb a minimális hossznál."
#: pretix/base/models/items.py
msgid ""
@@ -7489,9 +7474,6 @@ msgid "The payment for this invoice has already been received."
msgstr "A számla már ki lett fizetve."
#: pretix/base/payment.py
#, fuzzy
#| msgid ""
#| "This payment is already being processed and can not be canceled any more."
msgid ""
"This payment is already being processed and cannot be canceled any more."
msgstr "Ez a fizetés már feldolgozás alatt áll, és többé nem törölhető."
@@ -8267,16 +8249,12 @@ msgid "Presale end"
msgstr "Elővétel vége"
#: pretix/base/reldate.py
#, fuzzy
#| msgid "Order email"
msgid "Order creation"
msgstr "Rendelés e-mail címe"
msgstr "Rendelés létrehozás"
#: pretix/base/reldate.py
#, fuzzy
#| msgid "Order expired"
msgid "Order expiry"
msgstr "Rendelés lejárt"
msgstr "Rendelés lejárat"
#: pretix/base/reldate.py
msgid "before"
+41 -78
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-07 18:00+0000\n"
"Last-Translator: Yasunobu YesNo Kawaguchi <kawaguti@gmail.com>\n"
"PO-Revision-Date: 2026-10-05 14:00+0000\n"
"Last-Translator: Hijiri Umemoto <hijiri@umemoto.org>\n"
"Language-Team: Japanese <https://translate.pretix.eu/projects/pretix/pretix/"
"ja/>\n"
"Language: ja\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.9\n"
"X-Generator: Weblate 2026.10\n"
#: pretix/_base_settings.py
msgid "English"
@@ -1776,16 +1776,12 @@ msgid "Transmission type"
msgstr "送信タイプ"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission status"
msgstr "送信タイプ"
msgstr "送信ステータス"
#: pretix/base/exporters/invoices.py
#, fuzzy
#| msgid "Transmission type"
msgid "Transmission date"
msgstr "送信タイプ"
msgstr "送信日"
#: pretix/base/exporters/invoices.py
msgid "Cancellation"
@@ -4400,10 +4396,8 @@ msgid "Initialization date"
msgstr "初期化日"
#: pretix/base/models/devices.py
#, fuzzy
#| msgid "Save to profile"
msgid "Security profile"
msgstr "プロフィールに保存します"
msgstr "セキュリティ・プロフィール"
#: pretix/base/models/discount.py
msgctxt "subevent"
@@ -5779,10 +5773,8 @@ msgid "Maximum value"
msgstr "最大値"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "Maximum length"
msgid "Minimum length"
msgstr "最大長"
msgstr "最小長"
#: pretix/base/models/items.py
msgid "Maximum length"
@@ -5853,10 +5845,8 @@ msgid "The maximum value must not be lower than the minimum value."
msgstr "最大値は最小値より低くできません。"
#: pretix/base/models/items.py
#, fuzzy
#| msgid "The maximum value must not be lower than the minimum value."
msgid "The maximum length must not be shorter than the minimum length."
msgstr "最大値は最小値より低くできません。"
msgstr "最大長は最小長より短くできません。"
#: pretix/base/models/items.py
msgid ""
@@ -10269,10 +10259,8 @@ msgstr ""
"す。上記で設定された\"支払いの最終日\"を過ぎてからは支払いを受け付けません。"
#: pretix/base/settings.py
#, fuzzy
#| msgid "Generate invoices for Sales channels"
msgid "Allow postponed payment choice for sales channels"
msgstr "請求書を生成する販売チャネル"
msgstr "販売チャネルの支払延期選択を許可する"
#: pretix/base/settings.py
msgid ""
@@ -10280,6 +10268,9 @@ msgid ""
"their order without selecting a payment method. This is useful whenever "
"orders are not created by the same person who is making the payment."
msgstr ""
"販売チャネルで支払いの延期が許可されている場合、顧客は支払い方法を選択せずに"
"注文を完了できます。これは、支払いを行う同じ人物によって注文が作成されていな"
"い場合に便利です。"
#: pretix/base/settings.py
msgid "Show start date"
@@ -16362,7 +16353,7 @@ msgstr "第三"
#: pretix/control/forms/rrule.py
msgctxt "rrule"
msgid "fourth"
msgstr ""
msgstr "4番目"
#: pretix/control/forms/rrule.py
msgctxt "rrule"
@@ -17442,28 +17433,20 @@ msgid "Queued emails have been aborted."
msgstr "キューに入っていた電子メールが、破棄されました。"
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The property has been created."
msgid "An organizer meta property has been created."
msgstr "プロパティが作成されました。"
msgstr "主催者メタプロパティが作成されました。"
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The selected property has been deleted."
msgid "An organizer meta property has been deleted."
msgstr "選択されたプロパティは削除されました。"
msgstr "オーガナイザーのメタプロパティが削除されました。"
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The organizer has been changed."
msgid "An organizer meta property has been changed."
msgstr "主催者が変更されました。"
msgstr "主催者のメタプロパティが変更されました。"
#: pretix/control/logdisplay.py
#, fuzzy
#| msgid "The product has been reordered."
msgid "An organizer meta property has been reordered."
msgstr "製品の並び順が変更されました。"
msgstr "オーガナイザーのメタプロパティが再配置されました。"
#: pretix/control/logdisplay.py
msgid "Gift card acceptance for another organizer has been added."
@@ -17673,7 +17656,7 @@ msgstr "二要素の緊急コードが生成されました。"
#: pretix/control/logdisplay.py
msgid "Drift and throttle values for two-factor devices have been reset."
msgstr ""
msgstr "二要素装置のドリフト値とスロットル値がリセットされました。"
#: pretix/control/logdisplay.py
#, python-brace-format
@@ -18611,10 +18594,8 @@ msgid "Cancel"
msgstr "キャンセル"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
#, fuzzy
#| msgid "Authorized apps"
msgid "Authorize"
msgstr "承認されたアプリ"
msgstr "承認する"
#: pretix/control/templates/pretixcontrol/auth/oauth_authorization.html
msgid "Error:"
@@ -21418,10 +21399,6 @@ msgstr ""
"銀行振込の参照としていくつかの場所で使用します。"
#: pretix/control/templates/pretixcontrol/events/create_basics.html
#, fuzzy
#| msgid ""
#| "We strongly recommend against using short forms of more then 16 "
#| "characters."
msgid ""
"We strongly recommend against using short forms of more than 16 characters."
msgstr "16文字を超える短縮形の使用は強くお勧めしません。"
@@ -27136,26 +27113,22 @@ msgstr "認証バックエンド"
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Reset drift and throttle"
msgstr ""
msgstr "ドリフトとスロットルをリセットする"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy
#| msgid "Disable two-factor authentication"
msgid "Available two-factor authentication methods"
msgstr "二要素認証を無効にする"
msgstr "選択可能な二要素認証"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy, python-format
#| msgid "Expired since %(date)s"
#, python-format
msgid "1 failed attempt since %(date)s"
msgid_plural "%(cnt)s failed attempts since %(date)s"
msgstr[0] "%(date)s以降失効"
msgstr[0] "%(date)s以降%(cnt)s回失敗"
#: pretix/control/templates/pretixcontrol/users/form.html
#, fuzzy, python-format
#| msgid "Valid until %(date)s"
#, python-format
msgid "Currently locked until %(date)s"
msgstr "%(date)sまで有効"
msgstr "現時点で%(date)sまでロックされています"
#: pretix/control/templates/pretixcontrol/users/form.html
msgid "Team memberships"
@@ -27676,18 +27649,16 @@ msgid "Invalid code, please try again."
msgstr "無効なコードです。もう一度お試しください。"
#: pretix/control/views/auth.py
#, fuzzy, python-brace-format
#| msgid "Invalid code, please try again."
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} minute."
msgid_plural "Invalid code. Please try again after waiting {value} minutes."
msgstr[0] "無効なコードです。もう一度お試しください。"
msgstr[0] "無効なコードです。{value}分後再度お試しください。"
#: pretix/control/views/auth.py
#, fuzzy, python-brace-format
#| msgid "Invalid code, please try again."
#, python-brace-format
msgid "Invalid code. Please try again after waiting {value} second."
msgid_plural "Invalid code. Please try again after waiting {value} seconds."
msgstr[0] "無効なコードです。もう一度お試しください。"
msgstr[0] "無効なコードです。{value}秒後再度お試しください。"
#: pretix/control/views/checkin.py
msgid "The selected check-ins have been reverted."
@@ -28533,10 +28504,8 @@ msgid "You entered an order in an event with a different currency."
msgstr "イベントで異なる通貨で注文を入力しました。"
#: pretix/control/views/orders.py
#, fuzzy
#| msgid "You entered an order in an event with a different currency."
msgid "You entered an order in an event that you do not have access to."
msgstr "イベントで異なる通貨で注文を入力しました。"
msgstr "アクセスできないイベントに注文が入力されました。"
#: pretix/control/views/orders.py
msgid ""
@@ -29394,10 +29363,8 @@ msgstr ""
"ください。このユーザーの過去の緊急トークンは引き続き有効です。"
#: pretix/control/views/users.py
#, fuzzy
#| msgid "The password has been reset."
msgid "The drift values for TOTP devices have been reset."
msgstr "パスワードがリセットされました。"
msgstr "TOTP デバイスのドリフト値がリセットされました。"
#: pretix/control/views/users.py
msgid "The new user has been created."
@@ -29412,14 +29379,12 @@ msgid "Bypass quota"
msgstr "クォータをバイパスする"
#: pretix/control/views/vouchers.py
#, fuzzy
#| msgid "Widget"
msgid "Budget"
msgstr "ウィジェット"
msgstr "予算"
#: pretix/control/views/vouchers.py
msgid "Budget used"
msgstr ""
msgstr "消化済み予算"
#: pretix/control/views/vouchers.py
msgid "The requested voucher does not exist."
@@ -33979,29 +33944,27 @@ msgid ""
"Not sure yet? You can complete your order first and then select a payment "
"method later."
msgstr ""
"まだ確かではありませんか?ご注文を先に完了し、その後でお支払い方法を選択でき"
"ます。"
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
msgid ""
"To do so, please first remove the payment methods you already selected above."
msgstr ""
msgstr "そのためには、まず上記で既に選択された支払い方法を削除してください。"
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy, python-format
#| msgid "Your event needs to be over to use this feature."
#, python-format
msgid "Your payment needs to be completed within %(minutes)s minutes."
msgstr "この機能を使用するには、イベントが終了している必要があります。"
msgstr "支払いは、%(minutes)s分以内に完了する必要があります。"
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy, python-format
#| msgid "Your event needs to be over to use this feature."
#, python-format
msgid "Your payment needs to be completed by %(deadline)s."
msgstr "この機能を使用するには、イベントが終了している必要があります。"
msgstr "支払いは、%(deadline)sまでに完了させる必要があります。"
#: pretix/presale/templates/pretixpresale/event/checkout_payment.html
#, fuzzy
#| msgid "Proceed with reset"
msgid "Proceed without selection"
msgstr "リセットを実行"
msgstr "選択なしで進む"
#: pretix/presale/templates/pretixpresale/event/checkout_questions.html
msgid "Before we continue, we need you to answer some questions."
File diff suppressed because it is too large Load Diff
+35 -55
View File
@@ -8,8 +8,9 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2025-10-17 16:55+0000\n"
"Last-Translator: fd <fd@denkena-consulting.com>\n"
"PO-Revision-Date: 2026-10-02 05:00+0000\n"
"Last-Translator: Translate pretix user 594 "
"<ivagvr@users.noreply.translate.pretix.eu>\n"
"Language-Team: Russian <https://translate.pretix.eu/projects/pretix/pretix-"
"js/ru/>\n"
"Language: ru\n"
@@ -18,7 +19,7 @@ msgstr ""
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=3; plural=n%10==1 && n%100!=11 ? 0 : n%10>=2 && "
"n%10<=4 && (n%100<10 || n%100>=20) ? 1 : 2;\n"
"X-Generator: Weblate 5.13.3\n"
"X-Generator: Weblate 2026.10\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -78,10 +79,8 @@ msgid "SOFORT"
msgstr ""
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#, fuzzy
#| msgid "Yes"
msgid "eps"
msgstr "Да"
msgstr ""
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "MyBank"
@@ -147,7 +146,7 @@ msgstr "Подтверждается ваш платёж…"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
msgid "Payment method unavailable"
msgstr ""
msgstr "Способ оплаты недоступен"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js
msgid "Placed orders"
@@ -200,21 +199,16 @@ msgid "Switch check-in list"
msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#, fuzzy
#| msgid "Check-in QR"
msgid "Search results"
msgstr "QR-код для регистрации"
msgstr "Результаты поиска"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#, fuzzy
#| msgctxt "widget"
#| msgid "Close ticket shop"
msgid "No tickets found"
msgstr "Закрыть билетную кассу"
msgstr "Нет доступных билетов"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Result"
msgstr ""
msgstr "Результат"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "This ticket requires special attention"
@@ -226,7 +220,7 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Entry"
msgstr ""
msgstr "Вход"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Exit"
@@ -275,7 +269,7 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Ticket not paid"
msgstr ""
msgstr "Билет не оплачен"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "This ticket is not yet paid. Do you want to continue anyways?"
@@ -295,21 +289,19 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Ticket already used"
msgstr ""
msgstr "Повторный проход по билету"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Information required"
msgstr ""
msgstr "Необходима информация"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#, fuzzy
#| msgid "Unknown error."
msgid "Unknown ticket"
msgstr "Неизвестная ошибка."
msgstr "Неизвестный билет"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Ticket type not allowed here"
msgstr ""
msgstr "Здесь нельзя пройти по этому билету"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Entry not allowed"
@@ -317,11 +309,11 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Ticket code revoked/changed"
msgstr ""
msgstr "Код билета аннулирован и/или изменен"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Ticket blocked"
msgstr ""
msgstr "Билет заблокирован"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Ticket not valid at this time"
@@ -337,7 +329,7 @@ msgstr ""
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
msgid "Order not approved"
msgstr ""
msgstr "Заказ не подтвержден"
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#, fuzzy
@@ -377,32 +369,20 @@ msgstr "Закрыть"
#: pretix/static/pretixbase/js/addressform.js
#: pretix/static/pretixpresale/js/ui/main.js
#, fuzzy
#| msgid "Cart expired"
msgid "required"
msgstr "Срок действия корзины истёк"
msgstr "обязательно"
#: pretix/static/pretixbase/js/asynctask.js
#, fuzzy
#| msgid ""
#| "Your request has been queued on the server and will now be processed. "
#| "Depending on the size of your event, this might take up to a few minutes."
msgid ""
"Your request is currently being processed. Depending on the size of your "
"event, this might take up to a few minutes."
msgstr ""
"Ваш запрос поставлен в очередь на сервере и будет обработан. В зависимости "
"от масштаба вашего мероприятия это может занять до нескольких минут."
"Ваш запрос принят в обработку. В зависимости от масштаба вашего мероприятия "
"это может занять до нескольких минут."
#: pretix/static/pretixbase/js/asynctask.js
#, fuzzy
#| msgid ""
#| "Your request has been queued on the server and will now be processed. "
#| "Depending on the size of your event, this might take up to a few minutes."
msgid "Your request has been queued on the server and will soon be processed."
msgstr ""
"Ваш запрос поставлен в очередь на сервере и будет обработан. В зависимости "
"от масштаба вашего мероприятия это может занять до нескольких минут."
msgstr "Ваш запрос поставлен в очередь на сервере и будет обработан."
#: pretix/static/pretixbase/js/asynctask.js
msgid ""
@@ -473,7 +453,7 @@ msgstr "Чтобы скопировать, нажмите Ctrl-C."
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid "Edit"
msgstr ""
msgstr "Редактировать"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid "Visualize"
@@ -532,7 +512,7 @@ msgstr ""
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "Duplicate"
msgstr ""
msgstr "Дубликат"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgctxt "entry_status"
@@ -1215,15 +1195,15 @@ msgstr ""
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Tuesday"
msgstr ""
msgstr "Вторник"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Wednesday"
msgstr ""
msgstr "Среда"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Thursday"
msgstr ""
msgstr "Четверг"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Friday"
@@ -1231,11 +1211,11 @@ msgstr ""
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Saturday"
msgstr ""
msgstr "Суббота"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "Sunday"
msgstr ""
msgstr "Воскресенье"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "January"
@@ -1251,23 +1231,23 @@ msgstr "март"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "April"
msgstr "апрель"
msgstr "Апрель"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "May"
msgstr "май"
msgstr "Май"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "June"
msgstr "июнь"
msgstr "Июнь"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "July"
msgstr "июль"
msgstr "Июль"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "August"
msgstr "август"
msgstr "Август"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "September"
@@ -1275,7 +1255,7 @@ msgstr "сентябрь"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "October"
msgstr "октябрь"
msgstr "Октябрь"
#: pretix/static/pretixpresale/widget/src/i18n.ts
msgid "November"
+3 -4
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
"PO-Revision-Date: 2026-09-30 17:00+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix/"
"sv/>\n"
"Language: sv\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.9\n"
"X-Generator: Weblate 2026.9.1\n"
#: pretix/_base_settings.py
msgid "English"
@@ -19239,7 +19239,6 @@ msgstr ""
#: pretix/presale/templates/pretixpresale/event/order_pay.html
#: pretix/presale/templates/pretixpresale/event/order_pay_change.html
#: pretix/presale/templates/pretixpresale/event/position_change.html
#, fuzzy
msgid "Continue"
msgstr "Fortsätt"
+3 -4
View File
@@ -8,8 +8,8 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-04 14:29+0000\n"
"Last-Translator: Linnea Thelander <linnea@coeo.events>\n"
"PO-Revision-Date: 2026-09-30 17:00+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: Swedish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/sv/>\n"
"Language: sv\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.9\n"
"X-Generator: Weblate 2026.9.1\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -133,7 +133,6 @@ msgstr "Mercado Pago"
#: pretix/plugins/paypal2/static/pretixplugins/paypal2/pretix-paypal.js
#: pretix/plugins/webcheckin/static/pretixplugins/webcheckin/i18n.ts
#: pretix/static/pretixpresale/js/ui/cart.js
#, fuzzy
msgid "Continue"
msgstr "Fortsätt"
File diff suppressed because it is too large Load Diff
+6 -4
View File
@@ -8,7 +8,7 @@ msgstr ""
"Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-09-30 07:26+0000\n"
"PO-Revision-Date: 2026-09-29 09:51+0000\n"
"PO-Revision-Date: 2026-10-02 05:00+0000\n"
"Last-Translator: Nate Horst <nate@agcthailand.org>\n"
"Language-Team: Thai <https://translate.pretix.eu/projects/pretix/pretix-js/"
"th/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=1; plural=0;\n"
"X-Generator: Weblate 2026.9.1\n"
"X-Generator: Weblate 2026.10\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js
msgid "Marked as paid"
@@ -426,7 +426,7 @@ msgstr "แก้ไข"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid "Visualize"
msgstr ""
msgstr "แสดงภาพผัง"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid ""
@@ -434,10 +434,12 @@ msgid ""
"or variations are not contained in any of your rule parts so people with "
"these tickets will not get in:"
msgstr ""
"กฎของคุณมีการกรองตามสินค้าหรือรูปแบบย่อยเสมอ แต่สินค้าหรือรูปแบบย่อยต่อไปนี้ไม่ได้ถูกรวมอยู่ในส่วนใ"
"ดของกฎ คุณจึงส่งผลให้ผู้ที่ถือตั๋วเหล่านี้จะไม่สามารถผ่านเข้างานได้:"
#: pretix/static/pretixcontrol/js/ui/checkinrules/App.vue
msgid "Please double-check if this was intentional."
msgstr ""
msgstr "โปรดตรวจสอบอีกครั้งว่าเป็นการดำเนินการโดยตั้งใจหรือไม่"
#: pretix/static/pretixcontrol/js/ui/checkinrules/constants.ts
msgid "All of the conditions below (AND)"
+3
View File
@@ -40,3 +40,6 @@ class SendMailApp(AppConfig):
def ready(self):
from . import signals # NOQA
from . import tasks # NOQA
def uninstalled(self, event):
event.sendmail_rules.update(enabled=False)
+11 -1
View File
@@ -161,7 +161,12 @@ class OrderMailForm(BaseMailForm):
),
label=pgettext_lazy('sendmail_form', 'Restrict to products'),
required=True,
queryset=Item.objects.none()
queryset=Item.objects.none(),
help_text=pgettext_lazy(
'sendmail_form',
'There may be multiple mails sent out to the same mail address if one order contains multiple attendee '
'products for it, if you restrict to products while also restricting mails to attendees only. '
'This is intended, as every one of those get linked to their own separate order page restricted to only that product.')
)
filter_checkins = forms.BooleanField(
label=_('Filter check-in status'),
@@ -374,6 +379,11 @@ class RuleForm(FormPlaceholderMixin, I18nModelForm):
del self.fields['subevent']
self.fields['limit_products'].queryset = Item.objects.filter(event=self.event)
self.fields['limit_products'].help_text = pgettext_lazy(
'sendmail_form',
'There may be multiple mails sent out to the same mail address if one order contains multiple attendee '
'products for it, if you restrict to products while also restricting mails to attendees only. '
'This is intended, as every one of those get linked to their own separate order page restricted to only that product.')
self.fields['schedule_type'] = forms.ChoiceField(
label=_('Type of schedule time'),
@@ -0,0 +1,35 @@
from django.db import migrations
from django.db.models import Exists, OuterRef
def activate_plugin(apps, schema_editor):
Event = apps.get_model("pretixbase", "Event")
ScheduledMail = apps.get_model("sendmail", "ScheduledMail")
Rule = apps.get_model("sendmail", "Rule")
events = (
Event.objects
.exclude(plugins__icontains="pretix.plugins.sendmail")
.filter(
Exists(
ScheduledMail.objects.filter(event=OuterRef('pk'), rule__enabled=True).exclude(
state__in=['completed', 'missed'])
)
)
)
for event in events:
plugins_active = event.plugins.split(',')
plugins_active.append('pretix.plugins.sendmail')
event.plugins = ','.join(plugins_active)
event.save(update_fields=['plugins'])
class Migration(migrations.Migration):
dependencies = [
("sendmail", "0012_remove_cross_event_scheduled_mails"),
]
operations = [
migrations.RunPython(activate_plugin),
]
+50 -35
View File
@@ -19,6 +19,7 @@
# 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 datetime import datetime, time, timedelta
from dateutil.tz import datetime_exists
@@ -39,6 +40,8 @@ from pretix.base.models import (
)
from pretix.base.models.base import LoggingMixin
logger = logging.getLogger(__name__)
class ScheduledMail(models.Model):
STATE_SCHEDULED = 'scheduled'
@@ -115,7 +118,7 @@ class ScheduledMail(models.Model):
filter_orders_by_op = False
op_qs = OrderPosition.objects.filter(
order__event=self.event,
order__event=e,
canceled=False,
)
@@ -167,14 +170,58 @@ class ScheduledMail(models.Model):
for o in orders:
with language(o.locale, e.settings.region):
positions = list(o.positions.all())
o_sent = False
send_to_order = send_to_orders
try:
ia = o.invoice_address
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress(order=o)
if send_to_orders and o.email:
if send_to_attendees:
parent_op = None
sent_to_positions = set()
for p in positions:
if p.addon_to_id is None:
# this op might have matching add-ons, so save for later
parent_op = p
elif not parent_op or p.addon_to_id != parent_op.id:
# this op is an add-on, but not to the current parent_op
# something got mixed up as add-ons should always come directly after their parent
logger.warning(f"Add-ons are mixed up for position #{p.positionid} in order {o.full_code}")
continue
if p.id not in position_ids:
# not a matching op, just there for parent_op
continue
if p.addon_to_id and (
not p.attendee_email or p.attendee_email == parent_op.attendee_email
):
# if op is addon and either has no mail or the same as parent => send to parent
p = parent_op
if p.pk in sent_to_positions:
# this position already got an email
continue
if not p.attendee_email or p.attendee_email == o.email:
# no email or same as order => send to order
send_to_order = True
continue
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
sent_to_positions.add(p.pk)
if send_to_order and o.email:
email_ctx = get_email_context(
event=e,
order=o,
@@ -184,38 +231,6 @@ class ScheduledMail(models.Model):
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
if send_to_attendees:
if not self.rule.all_products:
positions = [p for p in positions if p.item_id in limit_products]
if self.subevent_id:
positions = [p for p in positions if p.subevent_id == self.subevent_id]
for p in positions:
if p.id in position_ids:
if p.attendee_email and (p.attendee_email != o.email or not o_sent):
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
position=p,
event_or_subevent=self.subevent or e,
)
p.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.position.email.sent')
elif not o_sent and o.email:
email_ctx = get_email_context(
event=e,
order=o,
invoice_address=ia,
event_or_subevent=self.subevent or e,
)
o.send_mail(self.rule.subject, self.rule.template, email_ctx,
attach_ical=self.rule.attach_ical,
log_entry_type='pretix.plugins.sendmail.rule.order.email.sent')
o_sent = True
self.last_successful_order_id = o.pk
+1
View File
@@ -181,6 +181,7 @@ def sendmail_run_rules(sender, **kwargs):
state=ScheduledMail.STATE_MISSED
)
for m_id in mails.filter(
event__plugins__contains="pretix.plugins.sendmail",
state__in=(ScheduledMail.STATE_SCHEDULED, ScheduledMail.STATE_FAILED),
rule__enabled=True,
event__live=True,
+31 -9
View File
@@ -31,6 +31,7 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import logging
from datetime import datetime
from django.db.models import Exists, OuterRef, Q
@@ -43,6 +44,8 @@ from pretix.base.services.mail import mail
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
logger = logging.getLogger(__name__)
def _chunks(lst, n):
"""
@@ -70,6 +73,8 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
except InvoiceAddress.DoesNotExist:
ia = InvoiceAddress(order=o)
parent_op = None
sent_to_positions = set()
if recipients in ('both', 'attendees'):
for p in o.positions.annotate(
any_checkins=Exists(
@@ -85,10 +90,16 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
)
),
).prefetch_related('addons', 'subevent'):
if p.addon_to_id is not None:
if p.addon_to_id is None:
parent_op = p
elif not parent_op or p.addon_to_id != parent_op.id:
# this op is an add-on, but not to the current parent_op
# something got mixed up as add-ons should always come directly after their parent
logger.warning(f"Add-ons are mixed up for position #{p.positionid} in order {o.full_code}")
continue
if p.item_id not in items and not any(a.item_id in items for a in p.addons.all()):
if p.item_id not in items:
continue
if filter_checkins:
@@ -99,13 +110,11 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
if not allowed:
continue
if not p.attendee_email:
if recipients == 'attendees':
send_to_order = True
continue
if p.attendee_email == o.email and send_to_order:
continue
if p.addon_to_id and (
not p.attendee_email or p.attendee_email == parent_op.attendee_email
):
# if op is addon and either has no mail or the same as parent => send to parent
p = parent_op
if subevent and p.subevent_id != subevent:
continue
@@ -116,6 +125,18 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
if subevents_to and p.subevent.date_from >= subevents_to:
continue
if not p.attendee_email:
send_to_order = True
continue
if p.pk in sent_to_positions:
# this position already got an email
continue
if p.attendee_email == o.email:
send_to_order = True
continue
with language(o.locale, event.settings.region):
email_context = get_email_context(event=event, order=o, invoice_address=ia, position=p)
outgoing_mail = mail(
@@ -137,6 +158,7 @@ def send_mails_to_orders(event: Event, user: int, subject: dict, message: dict,
user=user,
data=outgoing_mail.log_data(),
)
sent_to_positions.add(p.pk)
if send_to_order and o.email:
with language(o.locale, event.settings.region):
+9 -10
View File
@@ -138,7 +138,7 @@ logger = logging.getLogger('pretix.plugins.stripe')
# - BLIK: ✗
# - Pay By Bank: ✓
# - TWINT: ✓
# - Wero: ✓ (No settings UI yet)
# - Wero: ✓
# - giropay: ✗ (deprecated)
# - Sofort: ✗ (deprecated)
#
@@ -529,15 +529,14 @@ class StripeSettingsHolder(BasePaymentProvider):
'before they work properly.'),
required=False,
)),
# Disabled for now, since still in closed Beta and only available to dedicated boarded accounts.
# ('method_wero',
# forms.BooleanField(
# label=_('Wero'),
# disabled=self.event.currency not in 'EUR',
# help_text=_('Some payment methods might need to be enabled in the settings of your Stripe account '
# 'before they work properly.'),
# required=False,
# )),
('method_wero',
forms.BooleanField(
label=_('Wero'),
disabled=self.event.currency not in 'EUR',
help_text=_('Some payment methods might need to be enabled in the settings of your Stripe account '
'before they work properly.'),
required=False,
)),
] + extra_fields + list(super().settings_form_fields.items()) + moto_settings
)
if not self.settings.connect_client_id or self.settings.secret_key:
+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/>.
#
+129
View File
@@ -0,0 +1,129 @@
import os
from django.core.exceptions import ValidationError
from django.db import transaction
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers, viewsets
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from .models import WalletLayout, WalletPlatformLayout
from .styles import AVAILABLE_PLATFORMS, AVAILABLE_STYLES_DICT
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"]]
obj = style(event=self.context["event"], layout=data["layout"])
obj.validate()
data["file_settings"] = obj.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
+137
View File
@@ -0,0 +1,137 @@
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 import File
from django.core.files.uploadedfile import SimpleUploadedFile, UploadedFile
from django.utils.translation import gettext_lazy as _
from pretix.control.forms import ClearableBasenameFileInput
from .models import WalletLayout, WalletLayoutItem
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
class WalletLayoutItemForm(forms.ModelForm):
is_layouts = True
layout = forms.ModelChoiceField(
queryset=WalletLayout.objects.none(),
label=_("Wallet Layout"),
empty_label=_("(Event default)"),
required=False
)
class Meta:
model = WalletLayoutItem
fields = ("layout",)
def __init__(self, *args, **kwargs):
event = kwargs.pop("event")
super().__init__(*args, **kwargs)
self.fields["layout"].queryset = event.wallet_layouts.all()
def save(self, commit=True):
if self.cleaned_data["layout"] is None:
if self.instance.pk:
self.instance.delete()
else:
self.instance.layout = self.cleaned_data["layout"]
return super().save(commit=commit)
+65
View File
@@ -0,0 +1,65 @@
from typing import Protocol, runtime_checkable
from django.utils import translation
from django.utils.formats import date_format
type MaybeTranslatedString = str | Localizable
@runtime_checkable
class Localizable(Protocol):
def localize(self, lng: str) -> str:
...
class LocalizableBase:
def localize(self, lng):
with translation.override(lng):
return str(self)
class FormattingLocalizableString(LocalizableBase):
def __init__(
self,
base_str: MaybeTranslatedString,
*format_args: MaybeTranslatedString,
**format_kwargs: MaybeTranslatedString,
):
self.base_str = base_str
self.format_args = format_args
self.format_kwargs = format_kwargs
def __str__(self):
return str(self.base_str).format(*self.format_args, **self.format_kwargs)
class GettextLocalizableString(LocalizableBase):
def __init__(self, base):
self.base = base
@classmethod
def gettext(cls, base):
"""Fake function so makemessages picks up these strings."""
return cls(base)
def __str__(self):
return translation.gettext(self.base)
class LocalizableDatetime(LocalizableBase):
def __init__(self, datetime, format):
self.datetime = datetime
self.format = format
def __str__(self):
return date_format(self.datetime, self.format)
def localizable_to_dict(string: MaybeTranslatedString, locales: list[str]):
translated = {}
for locale in locales:
with translation.override(locale):
translated[locale] = str(string)
return translated
@@ -0,0 +1,99 @@
# Generated by Django 5.2.13 on 2026-05-19 15:39
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
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",
),
),
],
),
]
@@ -0,0 +1,52 @@
# Generated by Django 5.2.17 on 2026-10-06 14:29
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0312_alter_customer_locale_alter_devicelastseen_device_and_more",
),
("wallet", "0003_walletlayoutfilesetting"),
]
operations = [
migrations.AlterField(
model_name="walletlayoutitem",
name="layout",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="item_assignments",
to="wallet.walletlayout",
),
),
migrations.CreateModel(
name="GoogleWalletInstance",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("type", models.CharField()),
("identifier", models.CharField()),
("data", models.JSONField()),
(
"event",
models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
to="pretixbase.event",
),
),
],
options={
"unique_together": {("type", "identifier")},
},
),
]
+127
View File
@@ -0,0 +1,127 @@
#
# 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.core.exceptions import ValidationError
from django.db import models
from django.db.models import Q, constraints
from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.models import LoggedModel
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"
)
]
ordering = ("-default", "name",)
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,
null=True,
on_delete=models.CASCADE,
related_name="item_assignments",
)
def clean(self):
if self.layout and 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 GoogleWalletType(models.TextChoices):
CLASS = "CL", _("Class")
OBJECT = "OB", _("Object")
class GoogleWalletInstance(models.Model):
event = models.ForeignKey("pretixbase.Event", on_delete=models.CASCADE)
type = models.CharField(choices=GoogleWalletType)
identifier = models.CharField()
data = models.JSONField()
class Meta:
unique_together = (("type", "identifier"),)
@@ -0,0 +1,13 @@
from .placeholders import get_wallet_placeholder_renderer, get_wallet_placeholders
from .base import WalletPlaceholderRenderer, get_available_context
from . import base
from . import placeholders
__all__ = [
"base",
"placeholders",
"get_available_context",
"get_wallet_placeholder_renderer",
"get_wallet_placeholders",
"WalletPlaceholderRenderer",
]
@@ -0,0 +1,303 @@
from collections.abc import Callable
from functools import cached_property
from typing import Any
from django.core.files import File
from ..i18n import (
MaybeTranslatedString,
)
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 content_type(self) -> str:
"""The content type this placeholder generates (text / image)"""
raise NotImplementedError()
@property
def label(self) -> MaybeTranslatedString:
"""The human readable name of this placeholder"""
raise NotImplementedError()
@property
def control_label(self) -> MaybeTranslatedString:
"""
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):
@property
def content_type(self) -> str:
return "text"
def render(self, **context) -> MaybeTranslatedString | 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) -> MaybeTranslatedString:
"""
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 not sample:
raise RuntimeError("`render` returned falsy value when rendering a sample")
return sample
class BaseWalletImagePlaceholder(BaseWalletPlaceholder):
@property
def content_type(self) -> str:
return "image"
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: MaybeTranslatedString,
args: set[str],
func: Callable[..., MaybeTranslatedString | None],
sample: (
None | MaybeTranslatedString | Callable[..., MaybeTranslatedString]
) = None,
):
self._identifier = identifier
self._label = label
self._args = args
self.render = func
self._sample = sample
def __repr__(self):
return f"FunctionalWalletTextPlaceholder(identifier={self.identifier!r}, label={self.label!r})"
@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) -> MaybeTranslatedString:
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: MaybeTranslatedString,
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 = 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))
@@ -0,0 +1,314 @@
from django.contrib.staticfiles import finders
from django.core.files import File
from django.dispatch import receiver
from django.templatetags.static import static
from pretix.base.models.event import Event
from pretix.base.templatetags.money import money_filter
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString, GettextLocalizableString, LocalizableDatetime,
)
from ..signals import register_wallet_placeholders
from .base import (
BaseWalletPlaceholder, FunctionalContextTransformation,
FunctionalWalletImagePlaceholder, FunctionalWalletTextPlaceholder,
WalletPlaceholderRenderer,
)
def get_wallet_placeholder_renderer(**kwargs):
return WalletPlaceholderRenderer(
**kwargs,
transformations=[
# TODO: make this extendable via signal?
FunctionalContextTransformation(
"item", {"order_position"}, lambda order_position: order_position.item
),
FunctionalContextTransformation(
"order", {"order_position"}, lambda order_position: order_position.order
),
FunctionalContextTransformation(
"event", {"order"}, lambda order: order.event
),
FunctionalContextTransformation(
"organizer", {"event"}, lambda event: event.organizer
),
FunctionalContextTransformation(
"subevent_or_event",
{"order_position"},
lambda order_position: order_position.subevent
or order_position.order.event,
),
FunctionalContextTransformation(
"seat",
{"order_position"},
lambda order_position: (
order_position.seat if order_position.seat_id else None
),
),
],
)
def get_wallet_placeholders(event) -> dict[str, dict[str, BaseWalletPlaceholder]]:
placeholders = {"text": {}, "image": {}}
for r, ps in register_wallet_placeholders.send(sender=event):
for placeholder in ps:
placeholders[placeholder.content_type][placeholder.identifier] = placeholder
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"))
def admission(subevent_or_event):
return (
LocalizableDatetime(
subevent_or_event.date_admission.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
)
if subevent_or_event.date_admission
else None
)
def date_from(subevent_or_event):
return LocalizableDatetime(
subevent_or_event.date_from.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
)
def admission_or_date_from(subevent_or_event):
return admission(subevent_or_event) or date_from(subevent_or_event)
def website(order_position, order):
if order_position.subevent:
return eventreverse_absolute(
order.event,
"presale:event.index",
{"subevent": order_position.subevent.pk},
)
else:
return eventreverse_absolute(order.event, "presale:event.index")
@receiver(
register_wallet_placeholders,
dispatch_uid="plugin_wallet_register_wallet_placeholders",
)
def base_text_placeholders(sender: Event, **kwargs):
return [
FunctionalWalletTextPlaceholder(
"event",
GettextLocalizableString.gettext("Event"),
{"event"},
lambda event: event.name,
),
FunctionalWalletTextPlaceholder(
"event_slug",
GettextLocalizableString.gettext("Event Slug"),
{"event"},
lambda event: event.slug,
),
FunctionalWalletTextPlaceholder(
"admission",
GettextLocalizableString.gettext("Admission"),
{"subevent_or_event"},
admission,
date_from,
),
FunctionalWalletTextPlaceholder(
"date_from",
GettextLocalizableString.gettext("Begin"),
{"subevent_or_event"},
date_from,
),
FunctionalWalletTextPlaceholder(
"admission_or_date_from",
GettextLocalizableString.gettext("Admission or Date From"),
{"subevent_or_event"},
admission_or_date_from,
),
FunctionalWalletTextPlaceholder(
"order",
GettextLocalizableString.gettext("Order Code"),
{"order"},
lambda order: order.code,
),
FunctionalWalletTextPlaceholder(
"total",
GettextLocalizableString.gettext("Order Total"),
{"event", "order"},
lambda event, order: money_filter(order.total, event.currency),
),
FunctionalWalletTextPlaceholder(
"order_email",
GettextLocalizableString.gettext("Order Email"),
{"order"},
lambda order: order.email,
),
FunctionalWalletTextPlaceholder(
"price",
GettextLocalizableString.gettext("Item Price"),
{"event", "order_position"},
lambda event, order_position: money_filter(
order_position.price, event.currency
),
),
FunctionalWalletTextPlaceholder(
"secret",
GettextLocalizableString.gettext("Order Secret (QR-Code-Content)"),
{"order_position"},
lambda order_position: order_position.secret,
),
FunctionalWalletTextPlaceholder(
"item",
GettextLocalizableString.gettext("Product"),
{"item"},
lambda item: item.name,
),
FunctionalWalletTextPlaceholder(
"item_description",
GettextLocalizableString.gettext("Product description"),
{"item"},
lambda item: item.description,
),
FunctionalWalletTextPlaceholder(
"item_with_variation",
GettextLocalizableString.gettext("Product and variation"),
{"order_position"},
lambda order_position: (
FormattingLocalizableString(
"{name} - {variation}",
name=order_position.item.name,
variation=order_position.variation.name,
)
if order_position.variation
else order_position.item.name
),
),
FunctionalWalletTextPlaceholder(
"seat",
GettextLocalizableString.gettext("Seat: Full name"),
{"seat"},
lambda seat: (
FormattingLocalizableString("{seat}", seat=seat) if seat else None
),
GettextLocalizableString.gettext("Ground floor, Row 3, Seat 4")
),
FunctionalWalletTextPlaceholder(
"seat_zone",
GettextLocalizableString.gettext("Seat: Zone"),
{"seat"},
lambda seat: seat.zone_name,
GettextLocalizableString.gettext("Ground floor"),
),
FunctionalWalletTextPlaceholder(
"seat_row",
GettextLocalizableString.gettext("Seat: Row"),
{"seat"},
lambda seat: seat.row_label or seat.row_name,
"C",
),
FunctionalWalletTextPlaceholder(
"seat_number",
GettextLocalizableString.gettext("Seat: Seat Number"),
{"seat"},
lambda seat: seat.seat_label or seat.seat_number,
"5",
),
FunctionalWalletTextPlaceholder(
"attendee_name",
GettextLocalizableString.gettext("Attendee name"),
{"order_position"},
lambda order_position: order_position.attendee_name,
),
FunctionalWalletTextPlaceholder(
"program_start",
GettextLocalizableString.gettext("Programm times: Start"),
{"item", "subevent_or_event"},
lambda item, subevent_or_event: (
LocalizableDatetime(
min(pt.start for pt in item.program_times.all()).astimezone(
subevent_or_event.timezone
),
"SHORT_DATETIME_FORMAT",
)
if item.program_times.all()
else None
),
),
FunctionalWalletTextPlaceholder(
"program_end",
GettextLocalizableString.gettext("Programm times: End"),
{"item", "subevent_or_event"},
lambda item, subevent_or_event: (
LocalizableDatetime(
max(pt.end for pt in item.program_times.all()).astimezone(
subevent_or_event.timezone
),
"SHORT_DATETIME_FORMAT",
)
if item.program_times.all()
else None
),
),
FunctionalWalletTextPlaceholder(
"organizer",
GettextLocalizableString.gettext("Organizer"),
{"organizer"},
lambda organizer: organizer.name,
),
FunctionalWalletTextPlaceholder(
"organizer_contact",
GettextLocalizableString.gettext("Organizer Contact"),
{"event"},
lambda event: event.settings.contact_mail,
"contact@example.com",
),
FunctionalWalletTextPlaceholder(
"order_code",
GettextLocalizableString.gettext("Order Code"),
{"order"},
lambda order: order.code,
),
FunctionalWalletTextPlaceholder(
"purchase_date",
GettextLocalizableString.gettext("Purchase Date"),
{"order", "subevent_or_event"},
lambda order, subevent_or_event: LocalizableDatetime(
order.datetime.astimezone(subevent_or_event.timezone),
"SHORT_DATETIME_FORMAT",
),
),
FunctionalWalletTextPlaceholder(
"website",
GettextLocalizableString.gettext("Website"),
{"order_position", "order"},
website,
),
# IMAGES
FunctionalWalletImagePlaceholder(
"poweredby_logo",
GettextLocalizableString.gettext("Logo"),
set(),
lambda: get_static_file("pretixplugins/wallet/logo.png"),
static("pretixplugins/wallet/logo.png"),
),
FunctionalWalletImagePlaceholder(
"poweredby_icon",
GettextLocalizableString.gettext("Icon"),
set(),
lambda: get_static_file("pretixplugins/wallet/icon.png"),
static("pretixplugins/wallet/icon.png"),
),
# TODO: Image upload
]
+72
View File
@@ -0,0 +1,72 @@
#
# 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.dispatch import receiver
from pretix.base.signals import (
register_global_settings, register_ticket_outputs,
)
from pretix.control.signals import item_forms
from .forms import WalletLayoutItemForm
from .models import WalletLayoutItem
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()
@receiver(item_forms, dispatch_uid="pretix_wallet_item_forms")
def control_item_forms(sender, request, item, **kwargs):
forms = []
try:
inst = WalletLayoutItem.objects.get(item=item)
except WalletLayoutItem.DoesNotExist:
inst = WalletLayoutItem(item=item)
forms.append(
WalletLayoutItemForm(
instance=inst,
event=sender,
data=(request.POST if request.method == "POST" else None),
prefix="wallet",
)
)
return forms
+31
View File
@@ -0,0 +1,31 @@
#
# 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_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.BaseWalletPlaceholder.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
@@ -0,0 +1,113 @@
<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")
button(type="button" @click="store.setStyle(store.style.identifier)") Reset style
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;
}
}
}
.walletsettings-panel .panel-body {
> :is(h1, h2, h3, h4, h5, h6):first-child {
margin-top: 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,60 @@
<script setup lang="ts">
import { computed, ref, useId, watchEffect } from "vue";
const gettext = (window as any).gettext;
const selectedFile = defineModel<{"name": string; "url": string;}>()
const props = defineProps<{
label?: LocalizableString;
errors?: string[];
help_text?: string;
filename?: string;
current_url?: string;
default?: string;
}>();
const id = useId();
function onChange(e) {
selectedFile.value = (e.target as HTMLInputElement).files[0] || null
}
const inputRef = ref<HTMLInputElement>();
function clearFile() {
selectedFile.value = null;
}
</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
div(v-if="!!selectedFile")
| {{ gettext("Currently") + ': ' }}
a(:href="selectedFile.url" data-lightbox="input") {{ selectedFile.name }}
.btn-group
button.btn.btn-default(type="button" @click="inputRef.click()") Select file
button.btn.btn-default(v-if="!!selectedFile || !!current_url" type="button" @click="clearFile") {{ gettext("Clear") }}
input(:id="id" @change="onChange" v-bind="$attrs" :required="$attrs.required && !current_url" type="file" style="display: none" ref="inputRef")
.help-block(v-if="!!help_text || !!props.default")
template(v-if="!!help_text") {{ help_text }}
br(v-if="!!help_text && !!props.default")
template(v-if="!!props.default")
a(:href="props.default" data-lightbox="input") {{ gettext("Show default") }}
.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,31 @@
<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[],
placeholders: Record<string, 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]))
}
if (modelValue.value === null) {
modelValue.value = {};
}
})
</script>
<template lang="pug">
input.form-control(v-if="!!modelValue" v-for="(human_readable, locale) in store.locales" v-model="modelValue[locale]" v-bind="$attrs" :lang="locale" :title="human_readable" :placeholder="placeholders?.[locale] || human_readable")
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
@@ -0,0 +1,86 @@
<script setup lang="ts">
import { useId, computed, onMounted, useTemplateRef, onUnmounted, onUpdated, onBeforeUpdate } from "vue";
const gettext = (window as any).gettext;
defineOptions({
inheritAttrs: false,
});
const {
label,
errors,
type = "text",
min=null,
max=null,
defaultVal=null
} = defineProps<{
label?: string;
errors?: string[];
type?: string;
help_text?: string;
inline?: boolean;
min?: number;
max?: number;
defaultVal?: string;
}>();
const modelValue = defineModel<string | null>();
const id = useId();
const inputRef = useTemplateRef("input");
const attrs = computed(() => {
if (type === "float") {
return { class: "appear-text", type: "number", step: "any", min, max };
} else {
return { type: "text" };
}
});
onMounted(() => {
if (type == "color") {
const jq_elem = $(inputRef.value)
jq_elem.colorpicker({
format: "hex",
align: "left",
customClass: "colorpicker-2x",
sliders: {
saturation: {
maxLeft: 200,
maxTop: 200,
},
hue: {
maxTop: 200,
},
alpha: {
maxTop: 200,
},
},
})
jq_elem.on('changeColor', (e) => e.value && (modelValue.value = e.value))
jq_elem.on('showPicker', () => {jq_elem.colorpicker('setValue', modelValue.value || defaultVal)});
}
});
onUnmounted(() => {
if (type == "color") {
$(inputRef.value).colorpicker("destroy");
}
});
</script>
<template lang="pug">
.form-group(:class="{'row':inline}")
label.control-label(:for="id" v-if="label" :class="{'col-md-3': inline}") {{ label }}
br(v-if="!$attrs.required")
span.optional(v-if="!$attrs.required") {{ gettext("Optional") }}
div(:class="{'col-md-9': inline}")
input.form-control(:id="id" ref="input" v-model="modelValue" v-bind="{...$attrs, ...attrs}")
.help-block(v-if="!!help_text") {{ help_text }}
.help-block(v-if="!!errors" v-for="error in errors") {{ error }}
</template>
<style lang="css" scoped>
.appear-text {
appearance: textfield;
}
</style>
@@ -0,0 +1,34 @@
<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,
help_text?: 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.help_text") {{ help_text }}
.help-block(v-if="props.errors" v-for="error in props.errors") {{ error }}
</template>
@@ -0,0 +1,135 @@
<script setup lang="ts">
import { computed, inject, watchEffect } from "vue";
import Select from "./input/select.vue";
import I18nInput from "./input/i18ninput.vue";
import TextContent from "./text-content.vue";
import { StoreKey } from "../walletStore";
import { i18nstringLocalize } from "../helpers.js";
const store = inject(StoreKey)!;
const gettext = (window as any).gettext;
const ngettext = (window as any).ngettext;
const interpolate = (window as any).interpolate;
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)),
};
}
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, i18nstringLocalize(v.label)]);
choices.push(["other", gettext("Other…")]);
return choices;
});
const overflowText = computed(() => {
if (!props.fieldgroup.max_entries) {
return ""
}
const formats = ngettext(
'Maximum one field in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
'Maximum %s fields in this fieldgroup can be shown on the pass. The remaining fields will be shown in the selected fieldgroup.',
props.fieldgroup.max_entries
);
return interpolate(formats, [props.fieldgroup.max_entries])
})
const placeholders = computed(() => {
return fieldConfig.value.entries.map((entry) => {
const placeholder =
entry.type === "placeholder"
? store.variables[props.fieldgroup.content_type][entry.content]
: null;
const placeholders = {};
let base;
if (i18nstringLocalize(entry.label)) {
base = entry.label
} else if (placeholder) {
base = placeholder.label
} else {
return null
}
for (const locale of Object.keys(store.locales)) {
placeholders[locale] = i18nstringLocalize(base, locale)
}
return placeholders
})
});
</script>
<template lang="pug">
.panel.panel-default.walletsettings-panel
.panel-heading
h3.panel-title {{ fieldgroup.name }}
.panel-body(v-if="fieldConfig")
span.text-muted(v-if="fieldgroup.description") {{ fieldgroup.description }}
h4 {{ gettext("Content") }}
.form-group
table.table.table-hover
thead
tr
th.col-5(v-if="fieldgroup.display == 'with_label'") {{ gettext('Label') }}
th(:class="'col-' + (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" :placeholders="placeholders[n - 1]")
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, i18nstringLocalize(v.label)])"
)
td.text-right
button.btn.btn-danger.form-control-static(type="button" @click="fieldConfig.entries.splice(n - 1, 1)" v-if="fieldConfig.entries.length > (fieldgroup.min_entries||0)")
i.fa.fa-trash
span.sr-only {{ gettext('Delete') }}
.text-left.text-muted(v-else) {{ gettext("At least one field in this group is required") }}
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" :help_text="overflowText")
</template>
@@ -0,0 +1,28 @@
<script setup lang="ts">
import { watchEffect } from '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?: LocalizableString;
content: LocalizableString;
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)" :class="display_class")
</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,153 @@
<script setup lang="ts">
import { computed, inject } from "vue";
import { StoreKey } from "../../walletStore.js";
import RowPreview from "./row-preview.vue";
const store = inject(StoreKey)!;
const { layout } = defineProps<{ layout: PreviewLayout }>();
const layoutStyle = computed(() => {
if (layout.style) {
return Object.fromEntries(
Object.entries(layout.style)
.map(([k, v]) => {
if (typeof v !== "string" && v.setting) {
const settingDef = store.styles[store.layout.style].settings.find(
(x) => x.identifier == v.setting,
);
const val = store.settings[v.setting];
if (!settingDef) {
v = null;
} else if (settingDef.type == "image" && val) {
v = `url(${val.url})`; // TODO: encode
} else if (settingDef.type == "image" && settingDef.default) {
v = `url(${settingDef.default})`; // TODO: encode
} else {
v = val;
}
} else if (v.value) {
v = v.value;
}
return [k, v];
})
.filter(([k, v]) => !!v),
);
}
});
</script>
<template lang="pug">
div(style="container-type: inline-size;")
div.pass-container(:style="layoutStyle")
div.pass-content
RowPreview(v-for="row of layout.rows" :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;
position: relative;
background-image: var(--background-image);
background-color: var(--background-color);
background-size: cover;
background-position: center;
}
.pass-container::before {
content: "";
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
backdrop-filter: blur(10px);
pointer-events: none;
}
.pass-content {
overflow: scroll;
display: flex;
flex-direction: column;
gap: 1em;
position: relative;
}
</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;
color: var(--label-color);
}
.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 {
object-fit: contain;
}
.fieldgroup-item-qrcode {
font-weight: bold;
background-color: lightgray;
color: black;
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;
}
.img-inline {
max-height: 3lh;
}
.img-circle-sm {
max-height: 2lh;
aspect-ratio: 1;
border-radius: 50%;
background-color: white;
object-fit: fill;
}
}
</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,36 @@
<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") {
if (val && "url" in val && val.url) {
return val.url;
} else if (settingDef.value.type == "image" && settingDef.value.default) {
return settingDef.value.default;
}
} 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,23 @@
<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" || field.type == "float" || field.type == "color"'
:required="field.required" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :inline="true"
:label="field.label" :type="field.type" :help_text="field.help_text"
:min="field.min" :max="field.max" :defaultVal="field.default")
FileInput(v-if='field.type == "image"' :label="field.label" :required="field.required"
:help_text="field.help_text" @update:modelValue="(v) => store.setSetting(field.identifier, v)"
:modelValue="store.settings[field.identifier]" :default="field.default")
</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,33 @@
export function i18nstringLocalize(s: LocalizableString, locale?: string): string {
if (typeof s === 'string') {
return s
}
if (s === null) {
return null
}
if (!locale) {
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]
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

@@ -0,0 +1,150 @@
type Platform = {
identifier: string;
name: string;
styles: Styles;
};
type Style = {
identifier: string;
name: string;
fieldgroups: FieldGroupDefinition[];
preview_layout: PreviewLayout[];
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: FieldGroupType;
identifier: string;
name: string;
description: string;
required: boolean;
};
type FieldGroupDefinition =
| PlaceholderFieldGroupDefinition
| PredefinedFieldGroupDefinition;
type FieldGroupDisplay = "plain" | "with_label" | "code";
type FieldGroupType = "placeholder" | "predefined";
type FieldContentType = "text" | "image";
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";
default_active: boolean;
};
type LocalizableString = null | string | Record<string, string>;
type PlaceholderFieldEntry = {
type: "placeholder";
label: LocalizableString;
content: string;
};
type CustomFieldEntry = {
type: "custom";
label?: LocalizableString;
content?: LocalizableString;
};
type FieldEntry = PlaceholderFieldEntry | CustomFieldEntry;
type Setting = {
identifier: string;
label: string;
type: "text" | "image" | "color" | "float";
required: boolean;
help_text: string;
default: 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 PreviewLayout = {"style": Record<string, string | {setting: string}>, "rows": 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: LocalizableString; label?: LocalizableString } & PreviewProps;
type PlaceholderFieldGroupPreview = {
fieldgroup: string;
} & PreviewProps;
type PredefinedFieldgroupPreview = {
fieldgroup: string;
sample: PreviewSample[];
} & PreviewProps;
type PreviewSample = {
content: LocalizableString;
label: LocalizableString;
};
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
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

@@ -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,331 @@
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 || fieldgroup.default_active };
} 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" || setting.type == 'color' || setting.type == 'float') {
settings[setting.identifier] =
this.layout.settings[setting.identifier];
} else if (setting.type == "image") {
const val = this.layout.file_settings[setting.identifier];
if (val && "file" in val && val.file) {
settings[setting.identifier] = {"name": val.file.name, "url": URL.createObjectURL(val.file)};
} else if (val && "url" in val && val.url) {
settings[setting.identifier] = val;
} else {
settings[setting.identifier] = null;
}
}
}
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
];
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" || setting.type == "color") &&
typeof value == "string"
) {
this.layout.settings[setting.identifier] = value;
} else if (setting.type == "float" && typeof value == "string") {
this.layout.settings[setting.identifier] = parseFloat(value);
} else if (setting.type == "float" && typeof value == "number") {
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,22 @@
from pretix.plugins.wallet.styles.base import PassStyle
from .apple import ApplePlatform, AppleWalletEventTicket
from .google import GooglePlatform, GoogleWalletCustomEventTicket, GoogleWalletDefaultEventTicket
AVAILABLE_PLATFORMS = [ApplePlatform, GooglePlatform]
AVAILABLE_STYLES: dict[str, list[type[PassStyle]]] = {
"apple": [AppleWalletEventTicket],
"google": [GoogleWalletDefaultEventTicket, GoogleWalletCustomEventTicket],
}
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"]
+547
View File
@@ -0,0 +1,547 @@
import hashlib
import io
import json
import logging
import tempfile
import zipfile
from typing import Any
import cryptography
import cryptography.hazmat.primitives.hashes
import cryptography.hazmat.primitives.serialization.pkcs7
import cryptography.x509
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.files.uploadedfile import SimpleUploadedFile
from django.forms import ValidationError
from django.utils.encoding import force_bytes
from django.utils.translation import gettext as _
from pretix.base.models import OrderPosition
from pretix.plugins.wallet.i18n import (
FormattingLocalizableString, GettextLocalizableString, Localizable,
MaybeTranslatedString,
)
from pretix.plugins.wallet.styles.base import (
ColorSettingsField, FieldGroup, FieldGroupDisplay, FloatSettingsField,
ImageSettingsField, PassStyle, PlaceholderFieldEntry, TextFieldGroup,
WalletPlatform,
)
logger = logging.getLogger()
class ApplePlatform(WalletPlatform):
identifier = "apple"
name = _("Apple")
class StringResource:
entries: dict[str, MaybeTranslatedString]
locales: set[str]
def __init__(self, locales):
self.entries = {}
self.locales = set(locales)
def add_entry(self, key: str, value: MaybeTranslatedString):
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():
if isinstance(entry, str):
output += f'"{self.escape(key)}" = "{self.escape(entry)}";\n'
else:
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, ImageOps
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,
):
im = ImageOps.exif_transpose(im)
if max_size:
im.thumbnail(max_size, resample=Image.Resampling.BICUBIC)
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 [
ImageSettingsField(
identifier="logo",
label=_("Logo"),
help_text="Will be displayed on the top left corner of the pass",
default="pretixplugins/wallet/logo.png",
),
ImageSettingsField(
identifier="icon",
label=_("Icon"),
help_text="Will be displayed as the file icon",
default="pretixplugins/wallet/icon.png",
),
ImageSettingsField(identifier="background", label=_("Background Image")),
ColorSettingsField(identifier="bg_color", label=_("Background Color")),
ColorSettingsField(identifier="fg_color", label=_("Foreground Color")),
ColorSettingsField(identifier="label_color", label=_("Label Color")),
FloatSettingsField(identifier="lat", label=_("Latitude"), min=-90, max=90),
FloatSettingsField(
identifier="long", label=_("Longitude"), min=-180, max=180
),
]
def pass_content(self, fields, strings):
raise NotImplementedError()
def generate_pass_json(self, fields, op, strings):
event = op.subevent or op.order.event
tz = event.timezone
ticket = (
FormattingLocalizableString(
"{name} - {variation}", name=op.item.name, variation=op.variation.name
)
if op.variation
else op.item.name
)
description = FormattingLocalizableString(
GettextLocalizableString.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),
}
# TODO: Apples specs says this should be "specified as a CSS-style RGB triple, such as rgb(100, 10, 110).", but hex codes work as well
if bg_color := self.layout["settings"].get("bg_color"):
pass_json["backgroundColor"] = bg_color
if fg_color := self.layout["settings"].get("fg_color"):
pass_json["foregroundColor"] = fg_color
if label_color := self.layout["settings"].get("label_color"):
pass_json["labelColor"] = label_color
if "expirationDate" not in pass_json:
if op.valid_until:
pass_json["expirationDate"] = op.valid_until.astimezone(tz).isoformat()
elif event.settings.show_date_to and event.date_to:
pass_json["expirationDate"] = event.date_to.astimezone(tz).isoformat()
if "relevantDates" not in pass_json:
if op.valid_from and op.valid_to:
pass_json["relevantDates"] = [
{
"startDate": op.valid_from.astimezone(tz).isoformat(),
"endDate": op.valid_to.astimezone(tz).isoformat(),
}
]
elif op.valid_from:
pass_json["relevantDates"] = [
{"date": op.valid_from.astimezone(tz).isoformat()}
]
elif event.settings.show_date_to and event.date_to:
pass_json["relevantDates"] = [
{
"startDate": event.date_from.astimezone(tz).isoformat(),
"endDate": event.date_to.astimezone(tz).isoformat(),
}
]
else:
pass_json["relevantDates"] = [
{"date": event.date_from.astimezone(tz).isoformat()}
]
# TODO: also fill relevantDates from program times
if "locations" not in pass_json:
if "lat" in self.layout["settings"] and "long" in self.layout["settings"]:
pass_json["locations"] = {
"latitude": float(self.layout["settings"]["lat"]),
"longitude": float(self.layout["settings"]["long"]),
}
elif event.geo_lat and event.geo_lon:
pass_json["locations"] = {
"latitude": float(event.get_lat),
"longitude": float(event.get_lon),
}
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)
if file := self.file_settings.get("logo"):
logo = convert_to_png(
file, (480, 150)
) # TODO: check max_size against apple HIG
else:
logo = open(finders.find("pretixplugins/wallet/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("pretixplugins/wallet/icon.png"), "rb")
pkpass.add_file("icon.png", icon.read())
pkpass.add_file("logo.png", logo.read())
if file := self.file_settings.get("background"):
background = convert_to_png(
file, max_size=(540, 660)
) # TODO: check max_size against apple HIG
pkpass.add_file("background.png", background.read())
for lang, content in strings.generate().items():
print(lang, content)
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")
@property
def fieldgroups(self) -> list[FieldGroup]:
return [
TextFieldGroup(
identifier="logo_text",
name=_("Logo text"),
max_entries=1,
display=FieldGroupDisplay.PLAIN,
context_args={"order_position"},
),
TextFieldGroup(
identifier="header",
name=_("Header"),
max_entries=3,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="admission_or_date_from",
label=GettextLocalizableString.gettext(
"Admission"
),
),
],
),
TextFieldGroup(
identifier="primary",
name=_("Primary"),
min_entries=1,
max_entries=1,
default_entries=[
PlaceholderFieldEntry(
content="event",
)
],
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"},
default_entries=[
PlaceholderFieldEntry(
content="item_with_variation",
label=GettextLocalizableString.gettext(
"Product"
),
)
],
),
# 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="auxiliary",
name=_("Auxiliary"),
max_entries=4,
context_args={"order_position"},
default_entries=[
PlaceholderFieldEntry(
content="seat",
label=GettextLocalizableString.gettext(
"Seat"
),
),
PlaceholderFieldEntry(
content="attendee_name",
),
PlaceholderFieldEntry(
content="program_start",
label=GettextLocalizableString.gettext(
"From"
),
),
PlaceholderFieldEntry(
content="program_end",
label=GettextLocalizableString.gettext(
"To"
),
),
],
),
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"},
default_entries=[
PlaceholderFieldEntry(
content="admission",
),
PlaceholderFieldEntry(
content="attendee_name",
),
PlaceholderFieldEntry(
content="order_email",
label=GettextLocalizableString.gettext(
"Ordered by"
),
),
PlaceholderFieldEntry(
content="organizer",
),
PlaceholderFieldEntry(
content="organizer_contact",
),
PlaceholderFieldEntry(
content="order_code",
),
PlaceholderFieldEntry(
content="purchase_date",
),
PlaceholderFieldEntry(
content="website",
),
],
),
]
@property
def preview_layout(self):
return [
{
"style": {
"--background-color": {"setting": "bg_color"},
"color": {"setting": "fg_color"},
"--label-color": {"setting": "label_color"},
"--background-image": {"setting": "background"},
},
"rows": [
{
"children": [
{"setting": "logo", "display": ["img-inline"]},
{
"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"},
],
},
{
"rows": [{"fieldgroup": "back", "direction": "column"}],
},
]
def convert_fields(self, strings, fields, prefix):
# TODO: detect if a string-value/string-label is the same as a generated field name
# eg if a placeholder returns "primary-0-label", this would not be shown to the user but instead replaces with the label
converted = []
for i, f in enumerate(fields):
converted_field = {**f, "key": f"{prefix}-{i}"}
if "label" in converted_field and isinstance(
converted_field["label"], Localizable
):
strings.add_entry(f"{prefix}-{i}-label", converted_field["label"])
converted_field["label"] = f"{prefix}-{i}-label"
if isinstance(
converted_field["value"], Localizable
):
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
+629
View File
@@ -0,0 +1,629 @@
import enum
from typing import Any, Literal, TypedDict
import jsonschema
from django.core.exceptions import ValidationError
from django.core.files import File
from django.templatetags.static import static
from i18nfield.strings import LazyI18nString
from pretix.api.helpers import handle_file_upload
from pretix.base.models import OrderPosition
from ..i18n import MaybeTranslatedString, localizable_to_dict
from ..placeholders import (
WalletPlaceholderRenderer, get_available_context,
get_wallet_placeholder_renderer, get_wallet_placeholders,
)
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, locales: list[str], 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: MaybeTranslatedString | None
content: T
def __init__(
self, type: FieldEntryType, content: T, label: MaybeTranslatedString | None = None
):
self.type = type
self.label = label
self.content = content
def asdict(self, locales: list[str]) -> dict:
return {
"type": self.type.value,
"content": self.content,
"label": localizable_to_dict(self.label, locales) if self.label is not None else None,
}
class PlaceholderFieldEntry(FieldEntry[str]):
type = FieldEntryType.PLACEHOLDER
content: str
def __init__(self, content: str, label: MaybeTranslatedString | None = None):
self.label = label
self.content = content
class CustomFieldEntry(FieldEntry[MaybeTranslatedString]):
type: FieldEntryType
content: MaybeTranslatedString
def asdict(self, locales: list[str]) -> dict:
return {
"type": self.type.value,
"content": localizable_to_dict(self.content, locales),
"label": localizable_to_dict(self.label, locales) if self.label else None,
}
class PredefinedFieldGroup(FieldGroup):
type = FieldGroupType.PREDEFINED
default_active: bool
def __init__(self, identifier: str, name: str, description=None, required=False, default_active=True):
self.identifier = identifier
self.name = name
self.required = required
self.description = description or ""
self.default_active = default_active
def layout_schema(
self,
remaining_fields: list["FieldGroup"],
context: LayoutContext,
):
return {"type": "object", "properties": {"active": {"type": "boolean"}}}
def asdict(self, locales: list[str], context: LayoutContext):
return {
**super().asdict(locales, context),
"default_active": self.default_active,
}
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, locales: list[str], context: LayoutContext):
return {
**super().asdict(locales, context),
"content_type": self.content_type.value,
"default_entries": [x.asdict(locales) 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)
SettingType = Literal["image", "text", "float", "color"]
class SettingsField:
identifier: str
label: str
type: SettingType
help_text: str | None
required: bool
default: str | None
def asdict(self, locales: list[str]):
return {
"identifier": self.identifier,
"label": self.label,
"type": self.type,
"help_text": self.help_text,
"required": self.required,
"default": self.default,
}
def layout_schema(self) -> dict[str, Any] | None:
raise NotImplementedError()
class TextSettingsField(SettingsField):
default: str | None
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
required: bool = False,
default: str | None = None,
):
self.identifier = identifier
self.label = label
self.type = "text"
self.help_text = help_text
self.required = required
self.default = default
def layout_schema(self) -> dict[str, Any] | None:
schema = {"type": "string"}
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
class ColorSettingsField(SettingsField):
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
required: bool = False,
default: str | None = None,
):
self.identifier = identifier
self.label = label
self.type = "color"
self.help_text = help_text
self.required = required
self.default = default
def layout_schema(self) -> dict[str, Any] | None:
# TODO: validate that '#' + 6 hex chars
schema = {"type": "string"}
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
class ImageSettingsField(SettingsField):
_default: str | File | None
def __init__(
self,
identifier: str,
label: str,
help_text: str | None = None,
default: str | File | None = None,
):
self.identifier = identifier
self.label = label
self.type = "image"
self.help_text = help_text
self.required = False
self._default = default
@property
def default(self):
if isinstance(self._default, str):
return static(self._default)
elif isinstance(self._default, File):
return self._default.url
def layout_schema(self):
return
class FloatSettingsField(SettingsField):
min: float | None
max: float | None
def __init__(
self,
identifier: str,
label: str,
help_text=None,
required: bool = False,
min: float | None = None,
max: float | None = None,
default: str | None = None,
):
self.type = "float"
self.identifier = identifier
self.label = label
self.help_text = help_text
self.required = required
self.min = min
self.max = max
self.default = default
def layout_schema(self):
schema: dict[str, Any] = {"type": "number"}
if self.min is not None:
schema["minimum"] = self.min
if self.max is not None:
schema["maximum"] = self.max
if not self.required:
schema = {"oneOf": [schema, {"type": "null"}]}
return schema
def asdict(self, locales: list[str]):
return super().asdict(locales) | {"min": self.min, "max": self.max}
class PassStyle:
identifier: str # unique within platform
name: str
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)
@property
def fieldgroups(self) -> list[FieldGroup]:
# 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
return []
@property
def settings(self) -> list[SettingsField]:
return []
@property
def preview_layout(self) -> list | None:
return None
@property
def cleaned_settings(self):
if not self.layout:
return {}
settings = {}
for setting in self.settings:
if isinstance(setting, ImageSettingsField):
settings[setting.identifier] = self.file_settings[setting.identifier].url if setting.identifier in self.file_settings else None
if not settings[setting.identifier] and setting.default:
settings[setting.identifier] = setting.default
else:
settings[setting.identifier] = self.layout.get("settings", {}).get(
setting.identifier, setting.default
)
if not settings[setting.identifier] and setting.default:
settings[setting.identifier] = setting.default
return settings
def asdict(self): # -> dict[str, Any]:
context = LayoutContext(
placeholders=self.placeholders,
placeholder_renderer=get_wallet_placeholder_renderer(),
)
return {
"identifier": self.identifier,
"name": self.name,
"fieldgroups": [x.asdict(self.event.settings.locales, context) for x in self.fieldgroups],
"preview_layout": self.preview_layout,
"settings": [x.asdict(self.event.settings.locales) for x in self.settings],
}
def layout_schema(self):
context = LayoutContext(
placeholders=self.placeholders,
placeholder_renderer=get_wallet_placeholder_renderer(),
)
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: setting.layout_schema()
for setting in self.settings
if setting.layout_schema()
},
"required": [
setting.identifier
for setting in self.settings
if setting.required and setting.layout_schema()
],
},
},
"$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 and setting.type == "text" 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 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("`layout` 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: dict[str, MaybeTranslatedString | None] = {}
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"] = 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("`layout` needs to be set")
return self.layout["fieldgroups"].get(identifier, {}).get("active", False)
def generate(self, op: OrderPosition):
raise NotImplementedError()
+596
View File
@@ -0,0 +1,596 @@
import uuid
from functools import cached_property
from urllib.parse import urljoin
from django.conf import settings
from django.db import transaction
from django.utils import translation
from django.utils.formats import date_format
from django.utils.translation import gettext as _
from walletobjects import ButtonJWT, EventTicketClass, EventTicketObject
from walletobjects.comms import Comms
from walletobjects.constants import (
AnimationType,
Barcode,
ClassType,
ConfirmationCode,
DoorsOpen,
MultipleDevicesAndHoldersAllowedStatus,
ObjectState,
ObjectType,
ReviewStatus,
)
from pretix.base.models import Event, OrderPosition
from pretix.base.settings import GlobalSettingsObject
from pretix.multidomain.urlreverse import eventreverse_absolute
from pretix.plugins.wallet.i18n import localizable_to_dict
from pretix.plugins.wallet.models import GoogleWalletInstance, GoogleWalletType
from pretix.plugins.wallet.styles.base import (
ColorSettingsField,
FieldContentType,
FieldGroup,
FieldGroupDisplay,
FloatSettingsField,
ImageSettingsField,
PassStyle,
PlaceholderFieldEntry,
PlaceholderFieldGroup,
PredefinedFieldGroup,
TextFieldGroup,
WalletPlatform,
)
SHIMMER = False
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 GoogleWalletEventTicketStyle(PassStyle):
platform = GooglePlatform
@cached_property
def comms(self):
return Comms(self.event.settings.get("wallet_google_credentials").read())
def put_item_cached(self, instance_type, item_type, object):
with transaction.atomic():
instance, created = GoogleWalletInstance.objects.get_or_create(
event=self.event,
type=instance_type,
identifier=object["id"],
defaults={"data": object},
)
if created or instance.data != object:
result = self.comms.put_item(item_type, object["id"], object)
instance.data = object
instance.save(update_fields=["data"])
return result
def generate(self, op):
self.op = op
class_object = self._generate_class()
ticket_object = self._generate_object(op, class_id=class_object["id"])
self.put_item_cached(
GoogleWalletType.CLASS, ClassType.eventTicketClass, class_object
)
self.put_item_cached(
GoogleWalletType.OBJECT, ObjectType.eventTicketObject, ticket_object
)
generated_jwt = self.comms.sign_jwt(
ButtonJWT(
origins=[settings.SITE_URL],
issuer=self.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,
)
@property
def settings(self):
return [
ImageSettingsField(
identifier="logo",
label=_("Logo"),
default=self.event.settings.logo_image
or "pretixplugins/wallet/logo.png",
),
# TODO: wide logo
ImageSettingsField(
identifier="hero",
label=_("Hero Image"),
),
ColorSettingsField(
identifier="bg_color",
label=_("Background Color"),
default=self.event.settings.primary_color,
),
FloatSettingsField(identifier="lat", label=_("Latitude"), min=-90, max=90),
FloatSettingsField(
identifier="long", label=_("Longitude"), min=-180, max=180
),
]
@property
def fieldgroups(self) -> list[FieldGroup]:
return [
PredefinedFieldGroup(identifier="venue", name=_("Venue")),
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 [
{
"style": {
"--background-color": {"setting": "bg_color"},
"color": "contrast-color(var(--background-color))",
},
"rows": [
{
"children": [
{"setting": "logo", "display": ["img-circle-sm"]},
{
"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"],
},
*self._preview_layout_front_card,
{"fieldgroup": "code"},
{"setting": "hero"},
],
},
{"rows": [*self._preview_layout_back_details]},
]
@property
def _preview_layout_front_card(self):
return []
@property
def _preview_layout_back_details(self):
return []
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 = 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),
localizable_to_dict("Website", self.event.settings.locales),
)
if (lat := self.cleaned_settings["lat"]) and (
long := self.cleaned_settings["long"]
):
output_class.locations(lat, long)
elif self.event.geo_lat and self.event.geo_lon:
output_class.locations(self.event.geo_lat, self.event.geo_lon)
output_class.country_code(self.event.settings.locale)
if hero := self.cleaned_settings["hero"]:
output_class.hero_image(
urljoin(eventreverse_absolute(self.event, "presale:event.index"), hero),
self.event.name,
)
if logo := self.cleaned_settings["logo"]:
output_class.logo(
urljoin(eventreverse_absolute(self.event, "presale:event.index"), logo),
self.event.name,
)
output_class.hex_background_color(self.cleaned_settings["bg_color"])
output_class.confirmation_code_label(ConfirmationCode.orderNumber)
if self.group_is_active("venue") and all(self.venue()):
output_class.venue(*self.venue())
if SHIMMER:
output_class.securityAnimation(AnimationType.FOIL_SHIMMER)
# TODO: callback url
# output_class.callback_url(eventreverse_absolute(event.organizer,"plugins:wallet:google_webhook",))
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
)
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(op.order.code)
output_object.ticket_number(op.code)
output_object.valid_time_interval(op.valid_from, op.valid_until)
output_object.ticket_type(
get_translated_dict(
str(op.item)
+ (" – " + str(op.variation.value) if op.variation else ""),
op.order.event.settings.get("locales"),
)
)
return output_object
class GoogleWalletDefaultEventTicket(GoogleWalletEventTicketStyle):
identifier = "event"
name = "Default Event Ticket"
@property
def fieldgroups(self):
return [
PredefinedFieldGroup(identifier="date", name=_("Date")),
PredefinedFieldGroup(identifier="seating", name=_("Seating")),
PredefinedFieldGroup(identifier="ticket_holder", name=_("Ticket Holder")),
*super().fieldgroups,
]
@property
def _preview_layout_front_card(self):
return [
{
"fieldgroup": "date",
"sample": [
{
"content": date_format(
self.event.date_from.astimezone(self.event.timezone),
"SHORT_DATE_FORMAT",
),
"label": "Date",
},
{
"content": date_format(
self.event.date_from.astimezone(self.event.timezone),
"TIME_FORMAT",
),
"label": "Time",
},
],
},
{
"fieldgroup": "seating",
"sample": [
{"content": _("Ground floor"), "label": _("Section")},
{"content": "5 / 2", "label": _("Row") + " / " + _("Seat")},
],
},
]
@property
def _preview_layout_back_details(self):
return [
{
"fieldgroup": "ticket_holder",
"sample": [
{"content": _("John Doe"), "label": _("Ticket Holder")},
],
},
{
"fieldgroup": "venue",
"sample": [
{"content": self.venue()[1], "label": self.venue()[0]},
],
},
{
"fieldgroup": "date",
"sample": [
{
"content": date_format(
self.event.date_from.astimezone(self.event.timezone),
"DATETIME_FORMAT",
),
"label": _("Event Start Time"),
},
],
},
{"value": _("Sample product"), "label": _("Ticket Type")},
{"value": "ABCDE-1", "label": _("Ticket Number")},
{"value": "ABCDE", "label": _("Order Number")},
{
"value": eventreverse_absolute(self.event, "presale:event.index"),
"label": _("Website"),
},
]
def _generate_class(self):
output_class = super()._generate_class()
if self.group_is_active("date"):
if self.event.date_from:
output_class.start(self.event.date_from.isoformat())
if self.event.settings.show_date_to and self.event.date_to:
output_class.end(self.event.date_to.isoformat())
if self.event.date_admission:
output_class.doors_open(
DoorsOpen.doorsOpen, self.event.date_admission.isoformat()
)
return output_class
def ticket_holder_name(self, op):
return op.attendee_name or (op.addon_to.attendee_name if op.addon_to else None)
def _generate_object(self, op: OrderPosition, class_id: str):
output_object = super()._generate_object(op, class_id)
if self.group_is_active("ticket_holder") and self.ticket_holder_name(op):
output_object.ticket_holder_name(self.ticket_holder_name(op))
if self.group_is_active("seating") and (seat := self.op.seat):
if seat.zone_name:
output_object.section(seat.zone_name)
if seat.row_label:
output_object.row(seat.row_label)
elif seat.row_name:
output_object.row(seat.row_name)
if seat.seat_label:
output_object.seat(seat.seat_label)
elif seat.seat_number:
output_object.seat(seat.seat_number)
# places = settings.CURRENCY_PLACES.get(op.order.event.currency, 2)
# output_object.face_value(int(op.price * 1000 ** places), op.order.event.currency)
return output_object
class GoogleWalletCustomEventTicket(GoogleWalletEventTicketStyle):
identifier = "event_custom"
name = "Custom Event Ticket"
@property
def _preview_layout_front_card(self):
return [
{
"fieldgroup": "second_row",
},
{
"fieldgroup": "third_row",
},
]
@property
def _preview_layout_back_details(self):
return [{"fieldgroup": "details", "direction": "column"}]
@property
def fieldgroups(self):
return [
TextFieldGroup(
identifier="second_row",
name=_("Second Row"),
max_entries=3,
context_args={"order_position"},
),
TextFieldGroup(
identifier="third_row",
name=_("Third Row"),
max_entries=3,
context_args={"order_position"},
),
*super().fieldgroups,
TextFieldGroup(
identifier="details",
name=_("Details"),
context_args={"order_position"},
),
]
def _generate_class(self):
output_class = super()._generate_class()
output_class.setdefault("classTemplateInfo", {})
output_class["classTemplateInfo"].setdefault("cardTemplateOverride", {})
output_class["classTemplateInfo"]["cardTemplateOverride"] = {
"cardRowTemplateInfos": [
{
"threeItems": {
"startItem": {
"firstValue": {
"fields": [
{
"fieldPath": "object.textModulesData['second_row-0']"
}
]
}
},
"middleItem": {
"firstValue": {
"fields": [
{
"fieldPath": "object.textModulesData['second_row-1']"
}
]
}
},
"endItem": {
"firstValue": {
"fields": [
{
"fieldPath": "object.textModulesData['second_row-2']"
}
]
}
},
}
},
{
"threeItems": {
"startItem": {
"firstValue": {
"fields": [
{
"fieldPath": "object.textModulesData['third_row-0']"
}
]
}
},
"middleItem": {
"firstValue": {
"fields": [
{
"fieldPath": "object.textModulesData['third_row-1']"
}
]
}
},
"endItem": {
"firstValue": {
"fields": [
{
"fieldPath": "object.textModulesData['third_row-2']"
}
]
}
},
}
},
]
}
output_class["classTemplateInfo"].setdefault("detailsTemplateOverride", {})
output_class["classTemplateInfo"]["detailsTemplateOverride"][
"detailsItemInfo"
] = []
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)
for row in ["second_row", "third_row"]:
for i, field in enumerate(fields.get(row, [])):
output_object.text_modules_data(
f"{row}-{i}",
localizable_to_dict(field["label"], self.event.settings.locales),
localizable_to_dict(field["value"], self.event.settings.locales),
)
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 %}

Some files were not shown because too many files have changed in this diff Show More