Compare commits

...
Author SHA1 Message Date
Richard Schreiber 4d18e8391a Fix mail headers being None 2026-02-03 11:16:58 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
cead2898a7 Bump @babel/preset-env from 7.28.5 to 7.29.0 in /src/pretix/static/npm_dir (#5867)
Bumps [@babel/preset-env](https://github.com/babel/babel/tree/HEAD/packages/babel-preset-env) from 7.28.5 to 7.29.0.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.29.0/packages/babel-preset-env)

---
updated-dependencies:
- dependency-name: "@babel/preset-env"
  dependency-version: 7.29.0
  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-02-02 13:43:13 +01:00
Raphael MichelandGitHub 6a594a6166 Metrics: Fix length and age of of queues (broken after #5513) (#5865) 2026-02-02 13:37:16 +01:00
Raphael MichelandGitHub 0e7bb43a5a Manual payment: Fix using hidden method for existing order (#5850) 2026-02-02 12:32:53 +01:00
Richard SchreiberandGitHub 3a3ae6e66c Fix custom pycountry_add index handling (#5869) 2026-02-02 09:41:32 +01:00
Raphael Michel 48aecb80f6 Mail compat layer: Disable scopes 2026-01-30 12:38:51 +01:00
d58a6e2503 Tax rounding: Allow to apply only for B2B (Z#23220106) (#5810)
* Tax rounding: Allow to apply only for B2B (Z#23220106)

Most effective in combination with #5807

* Update src/pretix/base/settings.py

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-30 11:53:38 +01:00
Raphael Michel 8c4e0bdb82 Outgoing mails: Fix cross-browser support 2026-01-30 11:37:10 +01:00
Raphael MichelandGitHub c40e34af57 Model-based mail queuing 2026-01-30 10:43:02 +01:00
Richard SchreiberandGitHub 1492ec51bf Limit organizer ical to 1000 entries 2026-01-30 08:59:34 +01:00
robbi5andRaphael Michel 7ca2a0c910 Remove duplicate device/revoke from api documentation 2026-01-29 20:44:47 +01:00
Ruud HendrickxandRaphael Michel 0e41cb53a2 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
Ruud HendrickxandRaphael Michel 1d579d12c5 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 93.8% (5827 of 6207 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
Jiří PastrňákandRaphael Michel f3fa323351 Translations: Update Czech
Currently translated at 69.9% (4341 of 6207 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
Ruud HendrickxandRaphael Michel 67434bbe08 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 76.7% (4766 of 6207 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
Nate HorstandRaphael Michel 1f38d48ab7 Translations: Update Thai
Currently translated at 99.2% (254 of 256 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
Nate HorstandRaphael Michel 0b99ab74a1 Translations: Update Thai
Currently translated at 20.3% (1263 of 6207 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
Ruud HendrickxandRaphael Michel 9508e13ea8 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 91.0% (233 of 256 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
Ruud HendrickxandRaphael Michel 7efac71d62 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 75.5% (4690 of 6207 strings)

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

powered by weblate
2026-01-29 20:43:22 +01:00
26fdcc2872 Order changes: Do not allow to double-book add-ons (Z#23220592) (#5851)
* Order changes: Do not allow to double-book add-ons

* tests

* Update src/pretix/presale/templates/pretixpresale/event/fragment_addon_choice.html

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-29 20:42:43 +01:00
Richard SchreiberandGitHub 0e5e2193ed Fix auto-quantity change on free-price input
* Fix auto-quantity change on free-price input

* do not use one()
2026-01-29 14:19:09 +01:00
Richard SchreiberandGitHub 1e2900ad2a Markdown: fix double escaping URLs in safelink
* Markdown: fix double escaping URLs in safelink

* add tests

* fix isort
2026-01-29 12:14:12 +01:00
dependabot[bot]andRaphael Michel 4f521022f5 Update sentry-sdk requirement from ==2.50.* to ==2.51.*
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.50.0...2.51.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-29 09:25:47 +01:00
Ryo TagamiandRaphael Michel 5ce28ce258 Translations: Update Japanese
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ryo TagamiandRaphael Michel e51e765fcd Translations: Update Japanese
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel bb8301fbac Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 75.3% (193 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 5023081d6a Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 70.1% (4352 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Jiří PastrňákandRaphael Michel f2bf8e01e1 Translations: Update Czech
Currently translated at 69.9% (4341 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 65645a7e93 Translations: Update Dutch
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Nate HorstandRaphael Michel 296b17fb7b Translations: Update Thai
Currently translated at 56.6% (145 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Nate HorstandRaphael Michel fdc6de2a3d Translations: Update Thai
Currently translated at 12.6% (783 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 5c2c9c94c7 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 64.4% (165 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 277e63cce7 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 69.2% (4297 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel b0a031de93 Translations: Update Dutch
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ryo TagamiandRaphael Michel 6d770c66d6 Translations: Update Japanese
Currently translated at 99.2% (254 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Yasunobu YesNo KawaguchiandRaphael Michel d73155b69a Translations: Update Japanese
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ryo TagamiandRaphael Michel 839deabac3 Translations: Update Japanese
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 59c702588a Translations: Update Dutch
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel e1aaa422c9 Translations: Update Dutch
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ryo TagamiandRaphael Michel 27ae5ae018 Translations: Update Japanese
Currently translated at 99.7% (6189 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
CVZ-esandRaphael Michel 56c528795c Translations: Update Spanish
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
CVZ-esandRaphael Michel 6e70562839 Translations: Update Spanish
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
CVZ-esandRaphael Michel f7eff231ff Translations: Update French
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 52f78157f3 Translations: Update Dutch
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel e9a2633b01 Translations: Update Dutch
Currently translated at 99.6% (6187 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
CVZ-esandRaphael Michel 40932685fe Translations: Update French
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 5e66f21193 Translations: Update Dutch
Currently translated at 99.6% (6186 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Hijiri UmemotoandRaphael Michel 48683ce11d Translations: Update Chinese (Traditional Han script)
Currently translated at 92.1% (5720 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Hijiri UmemotoandRaphael Michel 8fc719b483 Translations: Update Japanese
Currently translated at 99.7% (6189 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Renne RochaandRaphael Michel c38859478c Translations: Update Portuguese (Brazil)
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Renne RochaandRaphael Michel 210115acef Translations: Update Portuguese (Brazil)
Currently translated at 89.9% (5586 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Ruud HendrickxandRaphael Michel 4db2384e93 Translations: Update Dutch
Currently translated at 99.6% (6186 of 6207 strings)

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

powered by weblate
2026-01-29 09:25:39 +01:00
Richard SchreiberandGitHub 803d0b1570 Fix missing locale in widget waitinglist 2026-01-26 16:52:37 +01:00
Raphael Michel 65fe7b3396 Bump version to 2026.2.0.dev0 2026-01-26 16:52:09 +01:00
Raphael Michel c94f7c35da Bump version to 2026.1.0 2026-01-26 16:51:58 +01:00
Ruud HendrickxandRaphael Michel c1b9e0df42 Translations: Update Dutch
Currently translated at 99.6% (6187 of 6207 strings)

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

powered by weblate
2026-01-26 14:57:04 +01:00
Raphael MichelandRaphael Michel 47cbd74ab5 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:57:04 +01:00
Raphael MichelandRaphael Michel 32369445d0 Translations: Update German
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:57:04 +01:00
Raphael Michel bb0a6a8001 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-01-26 14:20:46 +01:00
Raphael Michel 49aade373c Fix spellcheck issues 2026-01-26 14:19:39 +01:00
Ruud HendrickxandRaphael Michel 9dcd142112 Translations: Update Dutch
Currently translated at 99.6% (6187 of 6207 strings)

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

powered by weblate
2026-01-26 14:19:24 +01:00
Raphael MichelandRaphael Michel 68a64f577c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:19:24 +01:00
Raphael MichelandRaphael Michel 17eb6063d1 Translations: Update German
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-01-26 14:19:24 +01:00
Raphael Michel fd5dd989f7 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-01-26 10:10:17 +01:00
Ruud HendrickxandRaphael Michel 7fd1d91eb8 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 10:09:05 +01:00
Raphael Michel ef500c8924 Revert "Update po files"
This reverts commit 01a3546783.
2026-01-26 10:04:32 +01:00
Raphael Michel 01a3546783 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2026-01-26 10:03:14 +01:00
Raphael MichelandGitHub 3e0ff1e6ed Send security notification when recovery code is used or created by admin (#5719)
* Send security notification when recovery code is used or created by admin

"Where to store recovery codes" is one of these problems there is no
right answer to, so many people store them in a less-than-optimal place.
If that's the reality we live in, this PR adds at least a little
security so one notices when they get used :)

* Add sentence
2026-01-26 10:01:07 +01:00
Raphael MichelandGitHub 4edc7d95c6 Address form: Add missing province Aosta for Italy (#5796) (#5800) 2026-01-26 10:00:11 +01:00
Raphael Michel 7fb9e9a33d Bump django-formset-js-improved to 0.5.0.5 2026-01-26 09:58:13 +01:00
Raphael MichelandGitHub 8058461f10 Invoices: Allow issuing invoices only to businesses (Z#23220397) (#5807)
* Invoices: Allow issuing invoices only to businesses

In situations where every invoice has a significant accounting cost and
consumers usually do not need invoices, this can save a lot of money or
effort.

* Improve backend UI if not qualified for invoice
2026-01-26 09:52:19 +01:00
Raphael MichelandGitHub c84bd4046d Invoice address: Make Peppol required in Belgium if available (Z#23220397) (#5808)
* Invoice address: Make Peppol required in Belgium if available (Z#23220397)

* Fix failing test, remove template bit that's now impossible
2026-01-26 09:52:06 +01:00
Raphael MichelandGitHub 5e97f668a5 Order data export: Allow to filter by product (Z#23212618) (#5826)
* Order data export: Allow to filter by product (Z#23212618)

* Fix tests
2026-01-26 09:29:41 +01:00
Raphael Michel 5c8e785a6f Fix typo from merge conflict resolving 2026-01-26 09:29:22 +01:00
8e61ac6071 Invoice address: Add convenient autofill for Pepppol in Belgium (Z#23220397) (#5809)
* Invoice address: Add convenient autofill for Pepppol in Belgium (Z#23220397)

* Update src/pretix/static/pretixbase/js/addressform.js

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-26 09:12:07 +01:00
Raphael MichelandGitHub c3fd3a0838 Scheduled exports: Add copy button (Z#23221224) (#5823)
* Scheduled exports: Add copy button (Z#23221224)

* Update button label
2026-01-26 08:46:25 +01:00
Phin WolkwitzandGitHub 0d6e1e2271 Prefetch program times, add test for query count (#5822) 2026-01-26 08:38:44 +01:00
0af011eed4 Web check-in: Show addons of ticket (Z#23220213) (#5827)
* Web check-in: Show addons of ticket (Z#23220213)

* Update src/pretix/plugins/webcheckin/static/pretixplugins/webcheckin/components/app.vue

Co-authored-by: luelista <weller@rami.io>

---------

Co-authored-by: luelista <weller@rami.io>
2026-01-26 08:37:54 +01:00
Kian CrossandGitHub a0dae48cec Prevent double-clicks on SSO login providers (#5842) 2026-01-26 08:31:30 +01:00
Ruud HendrickxandRaphael Michel a53795ea88 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud HendrickxandRaphael Michel f1c0f24e25 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud HendrickxandRaphael Michel 980f4712a7 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud HendrickxandRaphael Michel bc8a8d8851 Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud HendrickxandRaphael Michel 10ec4d6c29 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Vajda TamásandRaphael Michel 584345cb99 Translations: Update Hungarian
Currently translated at 40.1% (102 of 254 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Vajda TamásandRaphael Michel 88545bcd05 Translations: Update Hungarian
Currently translated at 10.6% (657 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud HendrickxandRaphael Michel f034f4cde4 Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Ruud HendrickxandRaphael Michel fc6475b0bc Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-26 08:26:17 +01:00
Lukas BockstallerandGitHub aecc87ccdb handle open ended datetime ranges (#5838) 2026-01-23 12:25:28 +01:00
Raphael Michel 059179aecb Fix babel locale discovery for zh_Hans_US 2026-01-23 11:36:12 +01:00
Raphael MichelandGitHub fd72e18a7f Overview export: Allow to skip empty lines (Z#23219200) (#5825) 2026-01-23 11:18:07 +01:00
Raphael MichelandGitHub baac963fa8 API: Fix crash in check-in API (PRETIXEU-CT1) (#5806) 2026-01-23 11:17:21 +01:00
Ruud HendrickxandRaphael Michel 461ab2472f Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-23 09:00:20 +01:00
Ruud HendrickxandRaphael Michel 29d98f4182 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-23 09:00:20 +01:00
Raphael MichelandRaphael Michel 4f989cbe8a Order export: Add voucher_budget_use (Z#23218461) 2026-01-22 21:26:42 +01:00
Raphael MichelandRaphael Michel 23559e0711 Scheduled export: Move error message for missing permissions
This error message mostly occurs when working in admin mode and this
change allows our support team to still see what the form looks like to
guide users through, even if they can't save.
2026-01-22 21:26:33 +01:00
Ruud HendrickxandRaphael Michel 8787f79274 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Jiří PastrňákandRaphael Michel a7072d3b5b Translations: Update Czech
Currently translated at 70.0% (4340 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud HendrickxandRaphael Michel ff47ee7d68 Translations: Update Dutch
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud HendrickxandRaphael Michel 2c321f401d Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud HendrickxandRaphael Michel 180b92c87f Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 69.4% (4298 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud HendrickxandRaphael Michel c99751b319 Translations: Update Dutch
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud HendrickxandRaphael Michel 1f4205a9d9 Translations: Update Dutch
Currently translated at 96.1% (5952 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Ruud HendrickxandRaphael Michel 9e694982cf Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 67.8% (4199 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Jiří PastrňákandRaphael Michel ea5dbb05c2 Translations: Update Czech
Currently translated at 70.0% (4339 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
Linnea ThelanderandRaphael Michel e2ede76468 Translations: Update Swedish
Currently translated at 89.9% (5573 of 6193 strings)

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

powered by weblate
2026-01-22 10:32:36 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
498f5760af Update sentry-sdk requirement from ==2.49.* to ==2.50.* (#5828)
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.49.0...2.50.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.50.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-01-22 10:20:41 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
3b9ae7e560 Bump pycparser from 2.23 to 3.0 (#5832)
Bumps [pycparser](https://github.com/eliben/pycparser) from 2.23 to 3.0.
- [Release notes](https://github.com/eliben/pycparser/releases)
- [Commits](https://github.com/eliben/pycparser/compare/release_v2.23...release_v3.00)

---
updated-dependencies:
- dependency-name: pycparser
  dependency-version: '3.0'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:20:32 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
c08e3c054a Bump markdown from 3.10 to 3.10.1 (#5833)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10 to 3.10.1.
- [Release notes](https://github.com/Python-Markdown/markdown/releases)
- [Changelog](https://github.com/Python-Markdown/markdown/blob/master/docs/changelog.md)
- [Commits](https://github.com/Python-Markdown/markdown/compare/3.10.0...3.10.1)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: 3.10.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-01-22 10:20:24 +01:00
Raphael Michel 815e31d9a0 Resolve syntax warning in Pyton 3.14 2026-01-20 12:15:49 +01:00
Lukas BockstallerandGitHub ed618f2f32 add tiered availability by time (Z#23204747) (#5737)
* add tiered availability by time

* replace bitwise operator

* rephrase help text
2026-01-20 10:32:17 +01:00
Lukas BockstallerandGitHub a900e11ce0 Reduce queries for waitinglist autoassign n+1 (PRETIXEU-BJJ) (#5819)
* baseline of 574 queries

* reuse event from wle for locked_wle

reduces amount of queries to 556

* keep event accross refresh from db

drops queries from 556 to 471, halving the amount of queries for direct fetches for the event

* make numbers of queries reproducible by prewarming ContentTypeCache

* fix oversight

* correct number of queries to 335

* remove debug tooling

* remove assert_num_queries
2026-01-20 10:31:58 +01:00
Richard SchreiberandGitHub 112d5da792 Localize state names (#5744)
* Localize state names in js-helper

* localize statename in address-confirm

* add localized state_name to AbstractPosition and AttendeeProfile

* use state_for_address in order export
2026-01-20 10:13:20 +01:00
Richard SchreiberandGitHub ceb2e13d27 Remove autofocus from only button in cart-extend confirm-dialog (#5821) 2026-01-20 09:53:16 +01:00
Raphael Michel b5ad372bb2 Fix crash when not language is set 2026-01-16 21:39:38 +01:00
Raphael Michel cdea82d206 Peppol: Fix ID validator for Belgium (Z#23214624) 2026-01-16 21:38:55 +01:00
Raphael MichelandGitHub de9045afcf Allow to combine language variant with region (fixes #3947, Z#23220951) (#5814)
* Allow to combine language variant with region (fixes #3947, Z#23220951)

This only affects babel-based formatting (currently: currencies and phone numbers),
**not** Django-based formatting (currently: date and time formats).

* Remove tests where I don'T actually know whats right

* Fix lookup order
2026-01-16 17:08:46 +01:00
Kian CrossandGitHub 6b65cb4e33 Add daily and cumulative attendee graphs to the order statistics page (#5792)
The order statistics page previously only showed order-based graphs. This change
adds attendee-based daily and cumulative graphs.
2026-01-16 16:57:04 +01:00
Raphael MichelandGitHub c4792800f0 Cart: Fix wrong rounding being displayed (#5816) 2026-01-16 16:00:50 +01:00
Raphael MichelandGitHub ca23f7ebc2 License check: Recognize license keywords (fixes #5812) (#5815) 2026-01-16 15:33:03 +01:00
0259899e00 Discount: Respect addon grouping in line selection (Z#23220058) (#5782)
* Discount: Respect addon grouping in line selection (Z#23220058)

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

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

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-16 15:23:59 +01:00
Raphael MichelandGitHub efb94265b2 Cart: Use price before rounding as custom price for plus button (#5780) 2026-01-16 15:13:45 +01:00
Kian CrossandGitHub 2aa27f56f1 Exclude cancelled orders from paid orders graph (#5786)
The 'paid orders' time series on the statistics page currently counts orders
that were paid and later cancelled.

Filter the paid-by-day queryset to `Order.STATUS_PAID` with at least one
non-cancelled position, leaving the placed orders series unchanged, and update
the help text to clarify this behaviour.

Discussion: https://github.com/pretix/pretix/discussions/5774
2026-01-16 14:37:11 +01:00
4f3d90fc50 Bank transfer: Do not show reference before it is as complete as possible (fixes #5296) (#5621)
* Bank transfer: Do not show reference before it is as complete as possible (fixes #5296)

* Update src/pretix/plugins/banktransfer/payment.py

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

* Apply suggestion from @raphaelm

---------

Co-authored-by: Richard Schreiber <schreiber@pretix.eu>
2026-01-16 14:34:28 +01:00
Kian CrossandGitHub 9cf66de437 Clarify fee inclusion in revenue-over-time graph help text (#5785)
Updates the help text for the revenue-over-time graph to clarify how fees are
treated. When viewing a subevent, revenue excludes all fees (including
cancellation fees). When viewing the full event, revenue includes all fees,
including cancellation fees from cancelled orders.
2026-01-16 14:27:14 +01:00
Kian CrossandGitHub 9f4cbabd30 Include fee-cancelled positions in placed orders by product graph (#5791)
The 'placed orders by product' graph already includes orders that are pending,
expired, or fully cancelled without a fee. However, items cancelled with a fee
were omitted. This change ensures all placed orders are included in the graph,
including those cancelled with a fee.
2026-01-16 14:24:03 +01:00
0fc2d6134f Add option to restrict anonymous access to order URLs (#4735)
* Add option to restrict anonymous access to order URLs

By default, users who place orders while logged in can still access
their order URLs without authentication. This raises potential
security risks, particularly if order confirmation emails are
forwarded.

This commit introduces an organiser-level setting to disable anonymous
access for such orders. When enabled, unauthenticated attempts to access
URLs starting with `/order/`, which are intended for the customer, are
redirected to the login page. Upon successful authentication, the user
is redirected back to the original order URL.

It is important to note that this change does not impact routes intended
for attendees (e.g., `/ticket/*`), which remain accessible without
authentication.

* Change name of setting for future clarity

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Update message wording

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Eliminate database query

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Rename feature flag to fix breaking tests

* Refactor order access verification code into `OrderDetailsMixin`

* Add test for logged-in customer accessing another customer's order

* Refactor order access conditions to remove nesting

* Handle case where customer is not yet verified

* Add additional information to help message

* Fix multidomain issue

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>

* Merge order/position variants into single tests

* Add docstring explaining return type of `order` property

* Apply suggestion from @raphaelm

* Fix indentation

---------

Co-authored-by: Raphael Michel <mail@raphaelmichel.de>
Co-authored-by: Raphael Michel <michel@rami.io>
2026-01-16 13:46:08 +01:00
George HickmanandGitHub 1e0e16642d Add more log entry types to the org-level logs page (#5787)
* Add more log entry types to the org-level logs page

all_logentries() limits the QuerySet to LogEntrys whose content object
is an Organizer.

This change expands that to get any LogEntry linked to the current
Organization.  It removes those that are linked directly to an Event,
since they are already served by the event-level logs page.

* Check active plugins with either Event or Organizer
2026-01-16 13:36:23 +01:00
Richard SchreiberandRichard Schreiber a58403559e Translations: Update Swedish
Currently translated at 89.9% (5572 of 6193 strings)

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

powered by weblate
2026-01-16 10:24:59 +01:00
Lukas BockstallerandGitHub dfd53f0ea2 Waitinglist: lock entry to mitigate race-conditions when creating the voucher 2026-01-15 16:09:41 +01:00
Linnea ThelanderandRichard Schreiber 06250ef55e Translations: Update Swedish
Currently translated at 89.9% (5573 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario MontesandRichard Schreiber ab3104fe65 Translations: Update Galician
Currently translated at 15.9% (985 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
CVZ-esandRichard Schreiber bb6e424cde Translations: Update Spanish
Currently translated at 100.0% (254 of 254 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario MontesandRichard Schreiber c2623dba60 Translations: Update Spanish
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
CVZ-esandRichard Schreiber d8f7465b03 Translations: Update Spanish
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario MontesandRichard Schreiber ac0546499b Translations: Update Spanish
Currently translated at 98.8% (251 of 254 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Mario MontesandRichard Schreiber ebbb532478 Translations: Update Spanish
Currently translated at 99.9% (6192 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Hijiri UmemotoandRichard Schreiber 94dad4d0d2 Translations: Update Chinese (Traditional Han script)
Currently translated at 92.3% (5721 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
chondaen12andRichard Schreiber a06cd687ba Translations: Update Thai
Currently translated at 0.6% (41 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
sandra randRichard Schreiber fd9f3ea6ed Translations: Update Galician
Currently translated at 15.8% (984 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Hijiri UmemotoandRichard Schreiber 608622e3f3 Translations: Update Japanese
Currently translated at 100.0% (6193 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
Ruud HendrickxandRichard Schreiber 4d94294e5a Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 66.3% (4109 of 6193 strings)

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

powered by weblate
2026-01-15 16:01:46 +01:00
dependabot[bot]andGitHub 4dbdadabb5 Update sphinx-rtd-theme requirement from ~=3.1.0rc2 to ~=3.1.0 (#5804) 2026-01-13 13:23:35 +01:00
Jiří PastrňákandRaphael Michel d494c61cba Translations: Update Czech
Currently translated at 70.0% (4339 of 6193 strings)

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

powered by weblate
2026-01-12 09:44:26 +01:00
Jiří PastrňákandRaphael Michel 55a7dfbff3 Translations: Update Czech
Currently translated at 70.0% (4339 of 6193 strings)

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

powered by weblate
2026-01-12 09:44:26 +01:00
Jiří PastrňákandRaphael Michel b8c271cf9c Translations: Update Czech
Currently translated at 70.0% (4338 of 6193 strings)

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

powered by weblate
2026-01-12 09:44:26 +01:00
233 changed files with 109886 additions and 102530 deletions
-14
View File
@@ -208,20 +208,6 @@ Additionally, when creating a device through the user interface or API, a user c
the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security the device. These include an allow list of specific API calls that may be made by the device. pretix ships with security
policies for official pretix apps like pretixSCAN and pretixPOS. policies for official pretix apps like pretixSCAN and pretixPOS.
Removing a device
-----------------
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
invalidate your API key. There is no way to reverse this operation.
.. sourcecode:: http
POST /api/v1/device/revoke HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
This can also be done by the user through the web interface.
Event selection Event selection
--------------- ---------------
+1 -1
View File
@@ -1,5 +1,5 @@
sphinx==9.1.* sphinx==9.1.*
sphinx-rtd-theme~=3.1.0rc2 sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=1.8.1 sphinxcontrib-httpdomain~=1.8.1
sphinxcontrib-images~=1.0.1 sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1 sphinxcontrib-jquery~=4.1
+1 -1
View File
@@ -1,6 +1,6 @@
-e ../ -e ../
sphinx==9.1.* sphinx==9.1.*
sphinx-rtd-theme~=3.1.0rc2 sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=1.8.1 sphinxcontrib-httpdomain~=1.8.1
sphinxcontrib-images~=1.0.1 sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1 sphinxcontrib-jquery~=4.1
+4 -4
View File
@@ -41,7 +41,7 @@ dependencies = [
"django-compressor==4.6.0", "django-compressor==4.6.0",
"django-countries==8.2.*", "django-countries==8.2.*",
"django-filter==25.1", "django-filter==25.1",
"django-formset-js-improved==0.5.0.4", "django-formset-js-improved==0.5.0.5",
"django-formtools==2.5.1", "django-formtools==2.5.1",
"django-hierarkey==2.0.*,>=2.0.1", "django-hierarkey==2.0.*,>=2.0.1",
"django-hijack==3.7.*", "django-hijack==3.7.*",
@@ -65,7 +65,7 @@ dependencies = [
"kombu==5.6.*", "kombu==5.6.*",
"libsass==0.23.*", "libsass==0.23.*",
"lxml", "lxml",
"markdown==3.10", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3. "markdown==3.10.1", # 3.3.5 requires importlib-metadata>=4.4, but django-bootstrap3 requires importlib-metadata<3.
# We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7 # We can upgrade markdown again once django-bootstrap3 upgrades or once we drop Python 3.6 and 3.7
"mt-940==4.30.*", "mt-940==4.30.*",
"oauthlib==3.3.*", "oauthlib==3.3.*",
@@ -80,7 +80,7 @@ dependencies = [
"protobuf==6.33.*", "protobuf==6.33.*",
"psycopg2-binary", "psycopg2-binary",
"pycountry", "pycountry",
"pycparser==2.23", "pycparser==3.0",
"pycryptodome==3.23.*", "pycryptodome==3.23.*",
"pypdf==6.5.*", "pypdf==6.5.*",
"python-bidi==0.6.*", # Support for Arabic in reportlab "python-bidi==0.6.*", # Support for Arabic in reportlab
@@ -92,7 +92,7 @@ dependencies = [
"redis==7.1.*", "redis==7.1.*",
"reportlab==4.4.*", "reportlab==4.4.*",
"requests==2.32.*", "requests==2.32.*",
"sentry-sdk==2.49.*", "sentry-sdk==2.51.*",
"sepaxml==2.7.*", "sepaxml==2.7.*",
"stripe==7.9.*", "stripe==7.9.*",
"text-unidecode==1.*", "text-unidecode==1.*",
+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 # 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/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "2025.11.0.dev0" __version__ = "2026.2.0.dev0"
+1
View File
@@ -806,6 +806,7 @@ class EventSettingsSerializer(SettingsSerializer):
'invoice_reissue_after_modify', 'invoice_reissue_after_modify',
'invoice_include_free', 'invoice_include_free',
'invoice_generate', 'invoice_generate',
'invoice_generate_only_business',
'invoice_period', 'invoice_period',
'invoice_numbers_consecutive', 'invoice_numbers_consecutive',
'invoice_numbers_prefix', 'invoice_numbers_prefix',
+13 -2
View File
@@ -191,7 +191,7 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
{"transmission_info": {r: "This field is required for the selected type of invoice transmission."}} {"transmission_info": {r: "This field is required for the selected type of invoice transmission."}}
) )
break # do not call else branch of for loop break # do not call else branch of for loop
elif t.exclusive: elif t.is_exclusive(self.context["request"].event, data.get("country"), data.get("is_business")):
if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")): if t.is_available(self.context["request"].event, data.get("country"), data.get("is_business")):
raise ValidationError({ raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % ( "transmission_type": "The transmission type '%s' must be used for this country or address type." % (
@@ -704,6 +704,16 @@ class CheckinListOrderPositionSerializer(OrderPositionSerializer):
if 'answers.question' in self.context['expand']: if 'answers.question' in self.context['expand']:
self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True) self.fields['answers'].child.fields['question'] = QuestionSerializer(read_only=True)
if 'addons' in self.context['expand']:
# Experimental feature, undocumented on purpose for now in case we need to remove it again
# for performance reasons
subl = CheckinListOrderPositionSerializer(read_only=True, many=True, context={
**self.context,
'expand': [v for v in self.context['expand'] if v != 'addons'],
'pdf_data': False,
})
self.fields['addons'] = subl
class OrderPaymentTypeField(serializers.Field): class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2 # TODO: Remove after pretix 2.2
@@ -1601,7 +1611,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
order.sales_channel, order.sales_channel,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.price,
bool(cp.addon_to), cp.is_bundled, pos._voucher_discount) cp.addon_to, cp.is_bundled, pos._voucher_discount)
for cp in order_positions for cp in order_positions
] ]
) )
@@ -1733,6 +1743,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
rounding_mode = self.context["event"].settings.tax_rounding rounding_mode = self.context["event"].settings.tax_rounding
changed = apply_rounding( changed = apply_rounding(
rounding_mode, rounding_mode,
ia,
self.context["event"].currency, self.context["event"].currency,
[*pos_map.values(), *fees] [*pos_map.values(), *fees]
) )
+17 -19
View File
@@ -49,7 +49,7 @@ from pretix.base.plugins import (
PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID, PLUGIN_LEVEL_EVENT, PLUGIN_LEVEL_EVENT_ORGANIZER_HYBRID,
PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_ORGANIZER,
) )
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import mail
from pretix.base.settings import validate_organizer_settings from pretix.base.settings import validate_organizer_settings
from pretix.helpers.urls import build_absolute_uri as build_global_uri from pretix.helpers.urls import build_absolute_uri as build_global_uri
from pretix.multidomain.urlreverse import build_absolute_uri from pretix.multidomain.urlreverse import build_absolute_uri
@@ -363,24 +363,21 @@ class TeamInviteSerializer(serializers.ModelSerializer):
) )
def _send_invite(self, instance): def _send_invite(self, instance):
try: mail(
mail( instance.email,
instance.email, _('pretix account invitation'),
_('pretix account invitation'), 'pretixcontrol/email/invitation.txt',
'pretixcontrol/email/invitation.txt', {
{ 'user': self,
'user': self, 'organizer': self.context['organizer'].name,
'organizer': self.context['organizer'].name, 'team': instance.team.name,
'team': instance.team.name, 'url': build_global_uri('control:auth.invite', kwargs={
'url': build_global_uri('control:auth.invite', kwargs={ 'token': instance.token
'token': instance.token })
}) },
}, event=None,
event=None, locale=get_language_without_region() # TODO: expose?
locale=get_language_without_region() # TODO: expose? )
)
except SendMailException:
pass # Already logged
def create(self, validated_data): def create(self, validated_data):
if 'email' in validated_data: if 'email' in validated_data:
@@ -443,6 +440,7 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'customer_accounts', 'customer_accounts',
'customer_accounts_native', 'customer_accounts_native',
'customer_accounts_link_by_email', 'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
'contact_mail', 'contact_mail',
'imprint_url', 'imprint_url',
+38 -24
View File
@@ -381,15 +381,21 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
qs = qs.filter(reduce(operator.or_, lists_qs)) qs = qs.filter(reduce(operator.or_, lists_qs))
prefetch_related = [
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
]
select_related = [
'item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat'
]
if pdf_data: if pdf_data:
qs = qs.prefetch_related( qs = qs.prefetch_related(
Prefetch( # Don't add to list, we don't want to propagate to addons
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')),
Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related( Prefetch('order', Order.objects.select_related('invoice_address').prefetch_related(
Prefetch( Prefetch(
'event', 'event',
@@ -404,32 +410,39 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
) )
) )
)) ))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
) )
else:
qs = qs.prefetch_related(
Prefetch(
lookup='checkins',
queryset=Checkin.objects.filter(list_id__in=[cl.pk for cl in checkinlists]).select_related('device')
),
Prefetch('print_logs', queryset=PrintLog.objects.select_related('device')),
'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if expand and 'subevent' in expand: if expand and 'subevent' in expand:
qs = qs.prefetch_related( prefetch_related += [
'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set', 'subevent', 'subevent__event', 'subevent__subeventitem_set', 'subevent__subeventitemvariation_set',
'subevent__seat_category_mappings', 'subevent__meta_values' 'subevent__seat_category_mappings', 'subevent__meta_values'
) ]
if expand and 'item' in expand: if expand and 'item' in expand:
qs = qs.prefetch_related('item', 'item__addons', 'item__bundles', 'item__meta_values', prefetch_related += [
'item__variations').select_related('item__tax_rule') 'item', 'item__addons', 'item__bundles', 'item__meta_values',
'item__variations',
]
select_related.append('item__tax_rule')
if expand and 'variation' in expand: if expand and 'variation' in expand:
qs = qs.prefetch_related('variation', 'variation__meta_values') prefetch_related += [
'variation', 'variation__meta_values',
]
if expand and 'addons' in expand:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.prefetch_related(*prefetch_related).select_related(*select_related)),
]
else:
prefetch_related += [
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
]
if pdf_data:
select_related.remove("order") # Don't need it twice on this queryset
qs = qs.prefetch_related(*prefetch_related).select_related(*select_related)
return qs return qs
@@ -966,6 +979,7 @@ class CheckinRPCSearchView(ListAPIView):
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()
ctx['expand'] = self.request.query_params.getlist('expand') ctx['expand'] = self.request.query_params.getlist('expand')
ctx['organizer'] = self.request.organizer
ctx['pdf_data'] = False ctx['pdf_data'] = False
return ctx return ctx
+1 -1
View File
@@ -106,7 +106,7 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property', 'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property', 'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types', 'require_membership_types', 'variations__require_membership_types',
'limit_sales_channels', 'variations__limit_sales_channels', 'limit_sales_channels', 'variations__limit_sales_channels', 'program_times'
).all() ).all()
def perform_create(self, serializer): def perform_create(self, serializer):
+2 -12
View File
@@ -90,7 +90,6 @@ from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified, generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice, transmit_invoice, regenerate_invoice, transmit_invoice,
) )
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _order_placed_email, OrderChangeManager, OrderError, _order_placed_email,
_order_placed_email_attendee, approve_order, cancel_order, deny_order, _order_placed_email_attendee, approve_order, cancel_order, deny_order,
@@ -439,8 +438,6 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e: except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs) return self.retrieve(request, [], **kwargs)
return Response( return Response(
@@ -634,10 +631,7 @@ class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
order = self.get_object() order = self.get_object()
if not order.email: if not order.email:
return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': 'There is no email address associated with this order.'}, status=status.HTTP_400_BAD_REQUEST)
try: order.resend_link(user=self.request.user, auth=self.request.auth)
order.resend_link(user=self.request.user, auth=self.request.auth)
except SendMailException:
return Response({'detail': _('There was an error sending the mail. Please try again later.')}, status=status.HTTP_503_SERVICE_UNAVAILABLE)
return Response( return Response(
status=status.HTTP_204_NO_CONTENT status=status.HTTP_204_NO_CONTENT
@@ -1616,8 +1610,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
) )
except Quota.QuotaExceededException: except Quota.QuotaExceededException:
pass pass
except SendMailException:
pass
serializer = OrderPaymentSerializer(r, context=serializer.context) serializer = OrderPaymentSerializer(r, context=serializer.context)
@@ -1655,8 +1647,6 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e: except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST) return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs) return self.retrieve(request, [], **kwargs)
@action(detail=True, methods=['POST']) @action(detail=True, methods=['POST'])
@@ -2031,7 +2021,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
else: else:
order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id) order = Order.objects.select_for_update(of=OF_SELF).get(pk=inv.order_id)
c = generate_cancellation(inv) c = generate_cancellation(inv)
if inv.order.status != Order.STATUS_CANCELED: if invoice_qualified(order):
inv = generate_invoice(order) inv = generate_invoice(order)
else: else:
inv = c inv = c
+41 -6
View File
@@ -39,8 +39,8 @@ from zoneinfo import ZoneInfo
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.db.models import ( from django.db.models import (
Case, CharField, Count, DateTimeField, F, IntegerField, Max, Min, OuterRef, Case, CharField, Count, DateTimeField, Exists, F, IntegerField, Max, Min,
Q, Subquery, Sum, When, OuterRef, Q, Subquery, Sum, When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.dispatch import receiver from django.dispatch import receiver
@@ -144,6 +144,18 @@ class OrderListExporter(MultiSheetListExporter):
d = OrderedDict(d) d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents: if not self.is_multievent and not self.event.has_subevents:
del d['event_date_range'] del d['event_date_range']
if not self.is_multievent:
d["items"] = forms.ModelMultipleChoiceField(
label=_("Products"),
queryset=self.event.items.all(),
widget=forms.CheckboxSelectMultiple(
attrs={"class": "scrolling-multiple-choice"}
),
help_text=_("If none are selected, all products are included. Orders are included if they contain "
"at least one position of this product. The order totals etc. still include all products "
"contained in the order."),
required=False,
)
return d return d
def _get_all_payment_methods(self, qs): def _get_all_payment_methods(self, qs):
@@ -249,6 +261,14 @@ class OrderListExporter(MultiSheetListExporter):
pcnt=Subquery(s, output_field=IntegerField()) pcnt=Subquery(s, output_field=IntegerField())
).select_related('invoice_address', 'customer') ).select_related('invoice_address', 'customer')
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('pk'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='') qs = self._date_filter(qs, form_data, rel='')
if form_data['paid_only']: if form_data['paid_only']:
@@ -364,7 +384,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state, order.invoice_address.state_for_address,
order.invoice_address.custom_field, order.invoice_address.custom_field,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
@@ -440,6 +460,14 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
Exists(OrderPosition.all.filter(
order=OuterRef('order'),
item__in=form_data["items"]
))
)
qs = self._date_filter(qs, form_data, rel='order__') qs = self._date_filter(qs, form_data, rel='order__')
return qs return qs
@@ -515,7 +543,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state, order.invoice_address.state_for_address,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
@@ -535,6 +563,11 @@ class OrderListExporter(MultiSheetListExporter):
if form_data['paid_only']: if form_data['paid_only']:
qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False) qs = qs.filter(order__status=Order.STATUS_PAID, canceled=False)
if form_data.get('items'):
qs = qs.filter(
item__in=form_data["items"]
)
qs = self._date_filter(qs, form_data, rel='order__') qs = self._date_filter(qs, form_data, rel='order__')
return qs return qs
@@ -617,6 +650,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Country'), _('Country'),
pgettext('address', 'State'), pgettext('address', 'State'),
_('Voucher'), _('Voucher'),
_('Voucher budget usage'),
_('Pseudonymization ID'), _('Pseudonymization ID'),
_('Ticket secret'), _('Ticket secret'),
_('Seat ID'), _('Seat ID'),
@@ -732,8 +766,9 @@ class OrderListExporter(MultiSheetListExporter):
op.zipcode or '', op.zipcode or '',
op.city or '', op.city or '',
op.country if op.country else '', op.country if op.country else '',
op.state or '', op.state_for_address or '',
op.voucher.code if op.voucher else '', op.voucher.code if op.voucher else '',
op.voucher_budget_use if op.voucher_budget_use else '',
op.pseudonymization_id, op.pseudonymization_id,
op.secret, op.secret,
] ]
@@ -797,7 +832,7 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state, order.invoice_address.state_for_address,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
+1 -1
View File
@@ -1417,7 +1417,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.transmission_type = transmission_type.identifier self.instance.transmission_type = transmission_type.identifier
self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data) self.instance.transmission_info = transmission_type.form_data_to_transmission_info(data)
elif transmission_type.exclusive: elif transmission_type.is_exclusive(self.event, data.get("country"), data.get("is_business")):
if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")): if transmission_type.is_available(self.event, data.get("country"), data.get("is_business")):
raise ValidationError({ raise ValidationError({
"transmission_type": "The transmission type '%s' must be used for this country or address type." % ( "transmission_type": "The transmission type '%s' must be used for this country or address type." % (
+50 -12
View File
@@ -34,14 +34,13 @@
from contextlib import contextmanager from contextlib import contextmanager
from asgiref.local import Local
from babel import localedata from babel import localedata
from django.conf import settings from django.conf import settings
from django.utils import translation from django.utils import translation
from django.utils.formats import date_format, number_format from django.utils.formats import date_format, number_format
from django.utils.translation import gettext from django.utils.translation import gettext
from pretix.base.templatetags.money import money_filter
from i18nfield.fields import ( # noqa from i18nfield.fields import ( # noqa
I18nCharField, I18nTextarea, I18nTextField, I18nTextInput, I18nCharField, I18nTextarea, I18nTextField, I18nTextInput,
) )
@@ -51,6 +50,9 @@ from i18nfield.strings import LazyI18nString # noqa
from i18nfield.utils import I18nJSONEncoder # noqa from i18nfield.utils import I18nJSONEncoder # noqa
_active_region = Local()
class LazyDate: class LazyDate:
def __init__(self, value): def __init__(self, value):
self.value = value self.value = value
@@ -86,6 +88,8 @@ class LazyCurrencyNumber:
return self.__str__() return self.__str__()
def __str__(self): def __str__(self):
from pretix.base.templatetags.money import money_filter
return money_filter(self.value, self.currency) return money_filter(self.value, self.currency)
@@ -105,14 +109,41 @@ ALLOWED_LANGUAGES = dict(settings.LANGUAGES)
def get_babel_locale(): def get_babel_locale():
babel_locale = 'en' # Babel, and therefore also django-phonenumberfield, do not support our custom locales such das de_Informal
# Babel, and therefore django-phonenumberfield, do not support our custom locales such das de_Informal # Also, this returns best-effort region information for number formatting etc
if translation.get_language(): current_language = translation.get_language()
if localedata.exists(translation.get_language()): current_region = getattr(_active_region, "value", None)
babel_locale = translation.get_language()
elif localedata.exists(translation.get_language()[:2]): # Babel only accepts locales that exist on the system. We try combinations in the following order:
babel_locale = translation.get_language()[:2] # language-languageversion-region
return babel_locale # language-region
# language-languageversion
# language
# fallback to system default
# fallback to english
try_locales = []
if current_language:
if "-" in current_language:
lng_parts = current_language.split("-")
if current_region:
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{current_region.upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].upper()}")
try_locales.append(f"{lng_parts[0]}_{lng_parts[1].title()}")
try_locales.append(f"{lng_parts[0]}")
else:
if current_region:
try_locales.append(f"{current_language}_{current_region.upper()}")
try_locales.append(f"{current_language}")
try_locales.append(settings.LANGUAGE_CODE)
for locale in try_locales:
if localedata.exists(locale):
return localedata.normalize_locale(locale)
return "en"
def get_language_without_region(lng=None): def get_language_without_region(lng=None):
@@ -132,6 +163,10 @@ def get_language_without_region(lng=None):
return lng return lng
def set_region(region):
_active_region.value = region
@contextmanager @contextmanager
def language(lng, region=None): def language(lng, region=None):
""" """
@@ -143,15 +178,18 @@ def language(lng, region=None):
formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region`` formatting. If you pass a ``lng`` that already contains a region, e.g. ``pt-br``, the ``region``
attribute will be ignored. attribute will be ignored.
""" """
_lng = translation.get_language() lng_before = translation.get_language()
region_before = getattr(_active_region, "value", None)
lng = lng or settings.LANGUAGE_CODE lng = lng or settings.LANGUAGE_CODE
if '-' not in lng and region: if '-' not in lng and region:
lng += '-' + region.lower() lng += '-' + region.lower()
translation.activate(lng) translation.activate(lng)
_active_region.value = region
try: try:
yield yield
finally: finally:
translation.activate(_lng) translation.activate(lng_before)
_active_region.value = region_before
class LazyLocaleException(Exception): class LazyLocaleException(Exception):
+35 -39
View File
@@ -33,7 +33,7 @@ from pretix.base.invoicing.transmission import (
transmission_types, transmission_types,
) )
from pretix.base.models import Invoice, InvoiceAddress from pretix.base.models import Invoice, InvoiceAddress
from pretix.base.services.mail import SendMailException, mail, render_mail from pretix.base.services.mail import mail, render_mail
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
@@ -133,41 +133,37 @@ class EmailTransmissionProvider(TransmissionProvider):
template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString) template = invoice.order.event.settings.get('mail_text_order_invoice', as_type=LazyI18nString)
subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString) subject = invoice.order.event.settings.get('mail_subject_order_invoice', as_type=LazyI18nString)
try: # Do not set to completed because that is done by the email sending task
# Do not set to completed because that is done by the email sending task subject = format_map(subject, context)
subject = format_map(subject, context) email_content = render_mail(template, context)
email_content = render_mail(template, context) mail(
mail( [recipient],
[recipient], subject,
subject, template,
template, context=context,
context=context, event=invoice.order.event,
event=invoice.order.event, locale=invoice.order.locale,
locale=invoice.order.locale, order=invoice.order,
order=invoice.order, invoices=[invoice],
invoices=[invoice], attach_tickets=False,
attach_tickets=False, auto_email=True,
auto_email=True, attach_ical=False,
attach_ical=False, plain_text_only=True,
plain_text_only=True, no_order_links=True,
no_order_links=True, )
) invoice.order.log_action(
except SendMailException: 'pretix.event.order.email.invoice',
raise user=None,
else: auth=None,
invoice.order.log_action( data={
'pretix.event.order.email.invoice', 'subject': subject,
user=None, 'message': email_content,
auth=None, 'position': None,
data={ 'recipient': recipient,
'subject': subject, 'invoices': [invoice.pk],
'message': email_content, 'attach_tickets': False,
'position': None, 'attach_ical': False,
'recipient': recipient, 'attach_other_files': [],
'invoices': [invoice.pk], 'attach_cached_files': [],
'attach_tickets': False, }
'attach_ical': False, )
'attach_other_files': [],
'attach_cached_files': [],
}
)
+3 -1
View File
@@ -36,9 +36,11 @@ class ItalianSdITransmissionType(TransmissionType):
identifier = "it_sdi" identifier = "it_sdi"
verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)") verbose_name = pgettext_lazy("italian_invoice", "Italian Exchange System (SdI)")
public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)") public_name = pgettext_lazy("italian_invoice", "Exchange System (SdI)")
exclusive = True
enforce_transmission = True enforce_transmission = True
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
return str(country) == "IT"
def is_available(self, event, country: Country, is_business: bool): def is_available(self, event, country: Country, is_business: bool):
return str(country) == "IT" and super().is_available(event, country, is_business) return str(country) == "IT" and super().is_available(event, country, is_business)
+7 -1
View File
@@ -64,7 +64,7 @@ class PeppolIdValidator:
"0020": "[0-9]{9}", "0020": "[0-9]{9}",
"0201": "[0-9a-zA-Z]{6}", "0201": "[0-9a-zA-Z]{6}",
"0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}", "0204": "[0-9]{2,12}(-[0-9A-Z]{0,30})?-[0-9]{2}",
"0208": "0[0-9]{9}", "0208": "[01][0-9]{9}",
"0209": ".*", "0209": ".*",
"0210": "[A-Z0-9]+", "0210": "[A-Z0-9]+",
"0211": "IT[0-9]{11}", "0211": "IT[0-9]{11}",
@@ -179,6 +179,12 @@ class PeppolTransmissionType(TransmissionType):
def is_available(self, event, country: Country, is_business: bool): def is_available(self, event, country: Country, is_business: bool):
return is_business and super().is_available(event, country, is_business) return is_business and super().is_available(event, country, is_business)
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
if is_business and str(country) == "BE" and event and event.settings.invoice_address_from_country == "BE":
# Peppol is required to be used for intra-Belgian B2B invoices
return True
return False
@property @property
def invoice_address_form_fields(self) -> dict: def invoice_address_form_fields(self) -> dict:
return { return {
+9 -9
View File
@@ -58,15 +58,6 @@ class TransmissionType:
""" """
return 100 return 100
@property
def exclusive(self) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction.
"""
return False
@property @property
def enforce_transmission(self) -> bool: def enforce_transmission(self) -> bool:
""" """
@@ -82,6 +73,15 @@ class TransmissionType:
for provider, _ in providers for provider, _ in providers
) )
def is_exclusive(self, event, country: Country, is_business: bool) -> bool:
"""
If a transmission type is exclusive, no other type can be chosen if this type is
available. Use e.g. if a certain transmission type is legally required in a certain
jurisdiction. Event can be None in organizer-level contexts. Exclusiveness has no effect if
the type is not available.
"""
return False
def invoice_address_form_fields_required(self, country: Country, is_business: bool): def invoice_address_form_fields_required(self, country: Country, is_business: bool):
return set() return set()
+21 -7
View File
@@ -294,14 +294,28 @@ def metric_values():
channel = app.broker_connection().channel() channel = app.broker_connection().channel()
if hasattr(channel, 'client') and channel.client is not None: if hasattr(channel, 'client') and channel.client is not None:
client = channel.client client = channel.client
priority_steps = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("priority_steps", [0])
sep = settings.CELERY_BROKER_TRANSPORT_OPTIONS.get("sep", ":")
for q in settings.CELERY_TASK_QUEUES: for q in settings.CELERY_TASK_QUEUES:
llen = client.llen(q.name) queue_lengths = []
lfirst = client.lindex(q.name, -1) queue_delays = []
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = llen for prio in priority_steps:
if lfirst: if prio:
ldata = json.loads(lfirst) qname = f"{q.name}{sep}{prio}"
dt = time.time() - ldata.get('created', 0) else:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = dt qname = q.name
queue_length = client.llen(qname)
queue_lengths.append(queue_length)
oldest_queue_item = client.lindex(qname, -1)
if oldest_queue_item:
ldata = json.loads(oldest_queue_item)
oldest_item_age = time.time() - ldata.get('created', 0)
queue_delays.append(oldest_item_age)
metrics['pretix_celery_tasks_queued_count']['{queue="%s"}' % q.name] = sum(queue_lengths)
if queue_delays:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = max(queue_delays)
else: else:
metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0 metrics['pretix_celery_tasks_queued_age_seconds']['{queue="%s"}' % q.name] = 0
+5 -1
View File
@@ -35,7 +35,7 @@ from django.utils.translation.trans_real import (
parse_accept_lang_header, parse_accept_lang_header,
) )
from pretix.base.i18n import get_language_without_region from pretix.base.i18n import get_language_without_region, set_region
from pretix.base.settings import global_settings_object from pretix.base.settings import global_settings_object
from pretix.multidomain.urlreverse import ( from pretix.multidomain.urlreverse import (
get_event_domain, get_organizer_domain, get_event_domain, get_organizer_domain,
@@ -92,10 +92,14 @@ class LocaleMiddleware(MiddlewareMixin):
) )
if '-' not in language and settings_holder.settings.region: if '-' not in language and settings_holder.settings.region:
language += '-' + settings_holder.settings.region language += '-' + settings_holder.settings.region
if settings_holder.settings.region:
set_region(settings_holder.settings.region)
else: else:
gs = global_settings_object(request) gs = global_settings_object(request)
if '-' not in language and gs.settings.region: if '-' not in language and gs.settings.region:
language += '-' + gs.settings.region language += '-' + gs.settings.region
if gs.settings.region:
set_region(gs.settings.region)
translation.activate(language) translation.activate(language)
request.LANGUAGE_CODE = get_language_without_region() request.LANGUAGE_CODE = get_language_without_region()
@@ -0,0 +1,120 @@
# Generated by Django 4.2.26 on 2026-01-22 13:44
import uuid
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.mail
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0296_invoice_invoice_from_state"),
]
operations = [
migrations.CreateModel(
name="OutgoingMail",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("guid", models.UUIDField(db_index=True, default=uuid.uuid4)),
("status", models.CharField(default="queued", max_length=200)),
("created", models.DateTimeField(auto_now_add=True)),
("sent", models.DateTimeField(blank=True, null=True)),
("inflight_since", models.DateTimeField(blank=True, null=True)),
("retry_after", models.DateTimeField(blank=True, null=True)),
("error", models.TextField(null=True)),
("error_detail", models.TextField(null=True)),
("sensitive", models.BooleanField(default=False)),
("subject", models.TextField()),
("body_plain", models.TextField()),
("body_html", models.TextField(null=True)),
("sender", models.CharField(max_length=500)),
("headers", models.JSONField(default=dict)),
("to", models.JSONField(default=list)),
("cc", models.JSONField(default=list)),
("bcc", models.JSONField(default=list)),
("recipient_count", models.IntegerField()),
("should_attach_tickets", models.BooleanField(default=False)),
("should_attach_ical", models.BooleanField(default=False)),
("should_attach_other_files", models.JSONField(default=list)),
("actual_attachments", models.JSONField(default=list)),
(
"customer",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.customer",
),
),
(
"event",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.event",
),
),
(
"order",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.order",
),
),
(
"orderposition",
models.ForeignKey(
null=True,
on_delete=pretix.base.models.mail.CASCADE_IF_QUEUED,
related_name="outgoing_mails",
to="pretixbase.orderposition",
),
),
(
"organizer",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="outgoing_mails",
to="pretixbase.organizer",
),
),
(
"should_attach_cached_files",
models.ManyToManyField(
related_name="outgoing_mails", to="pretixbase.cachedfile"
),
),
(
"should_attach_invoices",
models.ManyToManyField(
related_name="outgoing_mails", to="pretixbase.invoice"
),
),
(
"user",
models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.CASCADE,
related_name="outgoing_mails",
to=settings.AUTH_USER_MODEL,
),
),
],
options={
"ordering": ("-created",),
},
),
]
+1
View File
@@ -41,6 +41,7 @@ from .items import (
itempicture_upload_to, itempicture_upload_to,
) )
from .log import LogEntry from .log import LogEntry
from .mail import OutgoingMail
from .media import ReusableMedium from .media import ReusableMedium
from .memberships import Membership, MembershipType from .memberships import Membership, MembershipType
from .notifications import NotificationSetting from .notifications import NotificationSetting
+16 -19
View File
@@ -334,27 +334,24 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return self.email return self.email
def send_security_notice(self, messages, email=None): def send_security_notice(self, messages, email=None):
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import mail
try: with language(self.locale):
with language(self.locale): msg = '- ' + '\n- '.join(str(m) for m in messages)
msg = '- ' + '\n- '.join(str(m) for m in messages)
mail( mail(
email or self.email, email or self.email,
_('Account information changed'), _('Account information changed'),
'pretixcontrol/email/security_notice.txt', 'pretixcontrol/email/security_notice.txt',
{ {
'user': self, 'user': self,
'messages': msg, 'messages': msg,
'url': build_absolute_uri('control:user.settings') 'url': build_absolute_uri('control:user.settings')
}, },
event=None, event=None,
user=self, user=self,
locale=self.locale locale=self.locale
) )
except SendMailException:
pass # Already logged
def send_confirmation_code(self, session, reason, email=None, state=None): def send_confirmation_code(self, session, reason, email=None, state=None):
""" """
+2 -1
View File
@@ -293,6 +293,7 @@ class Customer(LoggedModel):
locale=self.locale, locale=self.locale,
customer=self, customer=self,
organizer=self.organizer, organizer=self.organizer,
sensitive=True,
) )
def usable_gift_cards(self, used_cards=[]): def usable_gift_cards(self, used_cards=[]):
@@ -349,7 +350,7 @@ class AttendeeProfile(models.Model):
def state_name(self): def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd: if sd:
return sd.name return _(sd.name)
return self.state return self.state
@property @property
+41 -5
View File
@@ -37,7 +37,7 @@ from pretix.base.decimal import round_decimal
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
PositionInfo = namedtuple('PositionInfo', PositionInfo = namedtuple('PositionInfo',
['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'is_addon_to', ['item_id', 'subevent_id', 'subevent_date_from', 'line_price_gross', 'addon_to',
'voucher_discount']) 'voucher_discount'])
@@ -279,6 +279,42 @@ class Discount(LoggedModel):
for idx in condition_idx_group: for idx in condition_idx_group:
collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)] collect_potential_discounts[idx] = [(self, inf, -1, subevent_id)]
def _addon_idx(self, positions, idx):
"""
If we have the following cart:
- Main product
- 10x Addon product 5€
- Main product
- 10x Addon product 5€
And we have a discount rule that grants "every 10th product is free", people tend to expect
- Main product
- 9x Addon product 5€
- 1x Addon product free
- Main product
- 9x Addon product 5€
- 1x Addon product free
And get confused if they get
- Main product
- 8x Addon product 5€
- 2x Addon product free
- Main product
- 10x Addon product 5€
Even if the result is the same. Therefore, we sort positions in the cart not only by price, but also by their
relative index within their addon group. This is only a heuristic and there are *still* scenarios where the more
unexpected version happens, e.g. if prices are different. We need to accept this as long as discounts work on
cart level and not on addon-group level, but this simple sorting reduces the number of support issues by making
the weird case less likely.
"""
if not positions[idx].addon_to:
return 0
return len([1 for i, p in positions.items() if i < idx and p.addon_to == positions[idx].addon_to])
def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id): def _apply_min_count(self, positions, condition_idx_group, benefit_idx_group, result, collect_potential_discounts, subevent_id):
if len(condition_idx_group) < self.condition_min_count: if len(condition_idx_group) < self.condition_min_count:
return return
@@ -288,8 +324,8 @@ class Discount(LoggedModel):
if self.benefit_only_apply_to_cheapest_n_matches: if self.benefit_only_apply_to_cheapest_n_matches:
# sort by line_price # sort by line_price
condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx)) condition_idx_group = sorted(condition_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, -idx)) benefit_idx_group = sorted(benefit_idx_group, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx), -idx))
# Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only # Prevent over-consuming of items, i.e. if our discount is "buy 2, get 1 free", we only
# want to match multiples of 3 # want to match multiples of 3
@@ -434,7 +470,7 @@ class Discount(LoggedModel):
for idx, p in positions.items(): for idx, p in positions.items():
subevent_to_idx[p.subevent_id].append(idx) subevent_to_idx[p.subevent_id].append(idx)
for v in subevent_to_idx.values(): for v in subevent_to_idx.values():
v.sort(key=lambda idx: positions[idx].line_price_gross) v.sort(key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True) subevent_order = sorted(list(subevent_to_idx.keys()), key=lambda s: len(subevent_to_idx[s]), reverse=True)
# Build groups of exactly condition_min_count distinct subevents # Build groups of exactly condition_min_count distinct subevents
@@ -458,7 +494,7 @@ class Discount(LoggedModel):
# Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start # Sort the list by prices, then pick one. For "buy 2 get 1 free" we apply a "pick 1 from the start
# and 2 from the end" scheme to optimize price distribution among groups # and 2 from the end" scheme to optimize price distribution among groups
candidates = sorted(candidates, key=lambda idx: positions[idx].line_price_gross) candidates = sorted(candidates, key=lambda idx: (positions[idx].line_price_gross, self._addon_idx(positions, idx)))
if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0): if len(current_group) < (self.benefit_only_apply_to_cheapest_n_matches or 0):
candidate = candidates[0] candidate = candidates[0]
else: else:
+5 -4
View File
@@ -594,10 +594,11 @@ class Item(LoggedModel):
on_delete=models.SET_NULL, on_delete=models.SET_NULL,
verbose_name=_("Only show after sellout of"), verbose_name=_("Only show after sellout of"),
help_text=_("If you select a product here, this product will only be shown when that product is " help_text=_("If you select a product here, this product will only be shown when that product is "
"sold out. If combined with the option to hide sold-out products, this allows you to " "no longer available. This will happen either because the other product has sold out or because "
"swap out products for more expensive ones once the cheaper option is sold out. There might " "the time is outside of the sales window for the other product. If combined with the option "
"be a short period in which both products are visible while all tickets of the referenced " "to hide sold-out products, this allows you to swap out products for more expensive ones once "
"product are reserved, but not yet sold.") "the cheaper option is sold out. There might be a short period in which both products are visible "
"while all tickets of the referenced product are reserved, but not yet sold.")
) )
hidden_if_item_available_mode = models.CharField( hidden_if_item_available_mode = models.CharField(
choices=UNAVAIL_MODES, choices=UNAVAIL_MODES,
+2 -1
View File
@@ -141,8 +141,9 @@ class LogEntry(models.Model):
log_entry_type, meta = log_entry_types.get(action_type=self.action_type) log_entry_type, meta = log_entry_types.get(action_type=self.action_type)
if log_entry_type: if log_entry_type:
sender = self.event if self.event else self.organizer
link_info = log_entry_type.get_object_link_info(self) link_info = log_entry_type.get_object_link_info(self)
if is_app_active(self.event, meta['plugin']): if is_app_active(sender, meta['plugin']):
return make_link(link_info, log_entry_type.object_link_wrapper) return make_link(link_info, log_entry_type.object_link_wrapper)
else: else:
return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False, return make_link(link_info, log_entry_type.object_link_wrapper, is_active=False,
+222
View File
@@ -0,0 +1,222 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import uuid
from django.core.mail import get_connection
from django.db import models
from django.utils.translation import gettext_lazy as _
from django_scopes import scope, scopes_disabled
def CASCADE_IF_QUEUED(collector, field, sub_objs, using):
# If the email is still queued and the thing it is related to vanishes, the email can vanish as well
cascade_objs = [
o for o in sub_objs if o.status == OutgoingMail.STATUS_QUEUED
]
if cascade_objs:
models.CASCADE(collector, field, cascade_objs, using)
# In all other cases, set to NULL to keep the email on record
models.SET_NULL(collector, field, [o for o in sub_objs if o not in cascade_objs], using)
class OutgoingMail(models.Model):
STATUS_QUEUED = "queued"
STATUS_WITHHELD = "withheld"
STATUS_INFLIGHT = "inflight"
STATUS_AWAITING_RETRY = "awaiting_retry"
STATUS_FAILED = "failed"
STATUS_SENT = "sent"
STATUS_BOUNCED = "bounced"
STATUS_ABORTED = "aborted"
STATUS_CHOICES = (
(STATUS_QUEUED, _("queued")),
(STATUS_INFLIGHT, _("being sent")),
(STATUS_AWAITING_RETRY, _("awaiting retry")),
(STATUS_WITHHELD, _("withheld")), # for plugin use
(STATUS_FAILED, _("failed")),
(STATUS_ABORTED, _("aborted")),
(STATUS_SENT, _("sent")),
(STATUS_BOUNCED, _("bounced")), # for plugin use
)
STATUS_LIST_ABORTABLE = {
STATUS_QUEUED,
STATUS_WITHHELD,
STATUS_AWAITING_RETRY,
}
STATUS_LIST_RETRYABLE = {
STATUS_FAILED,
STATUS_WITHHELD,
}
# The GUID is a globally unique ID for the email added to a header of the email for later tracing
# in bug reports etc. We could theoretically also use this as a basis for the Message-ID header, but
# we currently don't since we are unsure if some intermediary SMTP servers have opinions on setting
# their own Message-ID headers.
guid = models.UUIDField(db_index=True, default=uuid.uuid4)
status = models.CharField(max_length=200, choices=STATUS_CHOICES, default=STATUS_QUEUED)
created = models.DateTimeField(auto_now_add=True)
# sent will be the time the email was sent or the email failed
sent = models.DateTimeField(null=True, blank=True)
inflight_since = models.DateTimeField(null=True, blank=True)
retry_after = models.DateTimeField(null=True, blank=True)
error = models.TextField(null=True, blank=True)
error_detail = models.TextField(null=True, blank=True)
# There is a conflict here between the different purposes of the model. As a system administrator,
# one wants *all* emails to be persisted as long as possible to debug issues. This means that if
# e.g. the event or order is deleted, we want SET_NULL behavior. However, in that case, the email
# would be an "orphan" forever and there's no way to remove the personal information.
# We try to find a middle-ground with the following behaviour:
# - The email is always deleted if the entire organizer or user is deleted
# - The email is always deleted if it has not yet been sent
# - The email is kept in all other cases
# This is only an acceptable trade-off since emails are stored for a short period only, and because
# orders and customers are never deleted during normal operation. If we ever make this a long-term
# storage / email archive, we'd need to find another way to make sure personal information is removed
# if personal information of orders etc is removed.
organizer = models.ForeignKey(
'pretixbase.Organizer',
on_delete=models.CASCADE,
related_name='outgoing_mails',
null=True, blank=True,
)
event = models.ForeignKey(
'pretixbase.Event',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
order = models.ForeignKey(
'pretixbase.Order',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
orderposition = models.ForeignKey(
'pretixbase.OrderPosition',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
customer = models.ForeignKey(
'pretixbase.Customer',
on_delete=CASCADE_IF_QUEUED,
related_name='outgoing_mails',
null=True, blank=True,
)
user = models.ForeignKey(
'pretixbase.User',
on_delete=models.CASCADE,
related_name='outgoing_mails',
null=True, blank=True,
)
sensitive = models.BooleanField(default=False)
subject = models.TextField()
body_plain = models.TextField()
body_html = models.TextField(null=True)
sender = models.CharField(max_length=500)
headers = models.JSONField(default=dict)
to = models.JSONField(default=list)
cc = models.JSONField(default=list)
bcc = models.JSONField(default=list)
recipient_count = models.IntegerField()
# We don't store the actual invoices, tickets or calendar invites, so if the email is re-sent at a later time, a
# newer version of the files might be used. We accept that risk to save on storage and also because the new
# version might actually be more useful.
should_attach_invoices = models.ManyToManyField(
'pretixbase.Invoice',
related_name='outgoing_mails'
)
should_attach_tickets = models.BooleanField(default=False)
should_attach_ical = models.BooleanField(default=False)
# clean_cached_files makes sure not to delete these as long as the email is in a retryable state
should_attach_cached_files = models.ManyToManyField(
'pretixbase.CachedFile',
related_name='outgoing_mails',
)
# This is used to send files stored in settings. In most cases, these aren't short-lived and should still be there
# if the email is sent. Otherwise, they will be skipped. We accept that risk.
should_attach_other_files = models.JSONField(default=list)
# [{name, type size}] of the attachments we actually setn
actual_attachments = models.JSONField(default=list)
class Meta:
ordering = ('-created',)
def get_mail_backend(self):
if self.event:
return self.event.get_mail_backend()
elif self.organizer:
return self.organizer.get_mail_backend()
else:
return get_connection(fail_silently=False)
def scope_manager(self):
if self.organizer:
return scope(organizer=self.organizer) # noqa
else:
return scopes_disabled() # noqa
@property
def is_failed(self):
return self.status in (
OutgoingMail.STATUS_FAILED,
OutgoingMail.STATUS_AWAITING_RETRY,
OutgoingMail.STATUS_BOUNCED,
)
def save(self, *args, **kwargs):
if self.orderposition_id and not self.order_id:
self.order = self.orderposition.order
if self.order_id and not self.event_id:
self.event = self.order.event
if self.event_id and not self.organizer_id:
self.organizer = self.event.organizer
if self.customer_id and not self.organizer_id:
self.organizer = self.customer.organizer
self.recipient_count = len(self.to) + len(self.cc) + len(self.bcc)
super().save(*args, **kwargs)
def log_parameters(self):
if self.order:
error_log_action_type = 'pretix.event.order.email.error'
log_target = self.order
elif self.customer:
error_log_action_type = 'pretix.customer.email.error'
log_target = self.customer
elif self.user:
error_log_action_type = 'pretix.user.email.error'
log_target = self.user
else:
error_log_action_type = 'pretix.email.error'
log_target = None
return log_target, error_log_action_type
+69 -91
View File
@@ -1167,9 +1167,7 @@ class Order(LockModel, LoggedModel):
only be attached for this position and child positions, the link will only point to the only be attached for this position and child positions, the link will only point to the
position and the attendee email will be used if available. position and the attendee email will be used if available.
""" """
from pretix.base.services.mail import ( from pretix.base.services.mail import mail, render_mail
SendMailException, mail, render_mail,
)
if not self.email and not (position and position.attendee_email): if not self.email and not (position and position.attendee_email):
return return
@@ -1179,35 +1177,31 @@ class Order(LockModel, LoggedModel):
if position and position.attendee_email: if position and position.attendee_email:
recipient = position.attendee_email recipient = position.attendee_email
try: email_content = render_mail(template, context)
email_content = render_mail(template, context) subject = format_map(subject, context)
subject = format_map(subject, context) mail(
mail( recipient, subject, template, context,
recipient, subject, template, context, self.event, self.locale, self, headers=headers, sender=sender,
self.event, self.locale, self, headers=headers, sender=sender, invoices=invoices, attach_tickets=attach_tickets,
invoices=invoices, attach_tickets=attach_tickets, position=position, auto_email=auto_email, attach_ical=attach_ical,
position=position, auto_email=auto_email, attach_ical=attach_ical, attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files, )
) self.log_action(
except SendMailException: log_entry_type,
raise user=user,
else: auth=auth,
self.log_action( data={
log_entry_type, 'subject': subject,
user=user, 'message': email_content,
auth=auth, 'position': position.positionid if position else None,
data={ 'recipient': recipient,
'subject': subject, 'invoices': [i.pk for i in invoices] if invoices else [],
'message': email_content, 'attach_tickets': attach_tickets,
'position': position.positionid if position else None, 'attach_ical': attach_ical,
'recipient': recipient, 'attach_other_files': attach_other_files,
'invoices': [i.pk for i in invoices] if invoices else [], 'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
'attach_tickets': attach_tickets, }
'attach_ical': attach_ical, )
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
def resend_link(self, user=None, auth=None): def resend_link(self, user=None, auth=None):
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
@@ -1675,7 +1669,7 @@ class AbstractPosition(RoundingCorrectionMixin, models.Model):
def state_name(self): def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd: if sd:
return sd.name return _(sd.name)
return self.state return self.state
@property @property
@@ -2024,40 +2018,30 @@ class OrderPayment(models.Model):
transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False)) transmit_invoice.apply_async(args=(self.order.event_id, invoice.pk, False))
def _send_paid_mail_attendee(self, position, user): def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid_attendee email_template = self.order.event.settings.mail_text_order_paid_attendee
email_subject = self.order.event.settings.mail_subject_order_paid_attendee email_subject = self.order.event.settings.mail_subject_order_paid_attendee
email_context = get_email_context(event=self.order.event, order=self.order, position=position) email_context = get_email_context(event=self.order.event, order=self.order, position=position)
try: position.send_mail(
position.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.order_paid', user,
'pretix.event.order.email.order_paid', user, invoices=[],
invoices=[], attach_tickets=True,
attach_tickets=True, attach_ical=self.order.event.settings.mail_attach_ical
attach_ical=self.order.event.settings.mail_attach_ical )
)
except SendMailException:
logger.exception('Order paid email could not be sent')
def _send_paid_mail(self, invoice, user, mail_text): def _send_paid_mail(self, invoice, user, mail_text):
from pretix.base.services.mail import SendMailException
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
email_template = self.order.event.settings.mail_text_order_paid email_template = self.order.event.settings.mail_text_order_paid
email_subject = self.order.event.settings.mail_subject_order_paid email_subject = self.order.event.settings.mail_subject_order_paid
email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text) email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
try: self.order.send_mail(
self.order.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.order_paid', user,
'pretix.event.order.email.order_paid', user, invoices=[invoice] if invoice else [],
invoices=[invoice] if invoice else [], attach_tickets=True,
attach_tickets=True, attach_ical=self.order.event.settings.mail_attach_ical
attach_ical=self.order.event.settings.mail_attach_ical )
)
except SendMailException:
logger.exception('Order paid email could not be sent')
@property @property
def refunded_amount(self): def refunded_amount(self):
@@ -2915,45 +2899,39 @@ class OrderPosition(AbstractPosition):
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download :param attach_tickets: Attach tickets of this order, if they are existing and ready to download
:param attach_ical: Attach relevant ICS files :param attach_ical: Attach relevant ICS files
""" """
from pretix.base.services.mail import ( from pretix.base.services.mail import mail, render_mail
SendMailException, mail, render_mail,
)
if not self.attendee_email: if not self.attendee_email:
return return
with language(self.order.locale, self.order.event.settings.region): with language(self.order.locale, self.order.event.settings.region):
recipient = self.attendee_email recipient = self.attendee_email
try: email_content = render_mail(template, context)
email_content = render_mail(template, context) subject = format_map(subject, context)
subject = format_map(subject, context) mail(
mail( recipient, subject, template, context,
recipient, subject, template, context, self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender, position=self,
position=self, invoices=invoices,
invoices=invoices, attach_tickets=attach_tickets,
attach_tickets=attach_tickets, attach_ical=attach_ical,
attach_ical=attach_ical, attach_other_files=attach_other_files,
attach_other_files=attach_other_files, )
) self.order.log_action(
except SendMailException: log_entry_type,
raise user=user,
else: auth=auth,
self.order.log_action( data={
log_entry_type, 'subject': subject,
user=user, 'message': email_content,
auth=auth, 'recipient': recipient,
data={ 'invoices': [i.pk for i in invoices] if invoices else [],
'subject': subject, 'attach_tickets': attach_tickets,
'message': email_content, 'attach_ical': attach_ical,
'recipient': recipient, 'attach_other_files': attach_other_files,
'invoices': [i.pk for i in invoices] if invoices else [], 'attach_cached_files': [],
'attach_tickets': attach_tickets, }
'attach_ical': attach_ical, )
'attach_other_files': attach_other_files,
'attach_cached_files': [],
}
)
def resend_link(self, user=None, auth=None): def resend_link(self, user=None, auth=None):
@@ -3480,7 +3458,7 @@ class InvoiceAddress(models.Model):
def state_name(self): def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state)) sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd: if sd:
return sd.name return _(sd.name)
return self.state return self.state
@property @property
+49 -46
View File
@@ -34,7 +34,8 @@ from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import User, Voucher from pretix.base.models import User, Voucher
from pretix.base.services.mail import SendMailException, mail, render_mail from pretix.base.services.mail import mail, render_mail
from pretix.helpers import OF_SELF
from ...helpers.format import format_map from ...helpers.format import format_map
from ...helpers.names import build_name from ...helpers.names import build_name
@@ -158,6 +159,7 @@ class WaitingListEntry(LoggedModel):
if availability[1] is None or availability[1] < 1: if availability[1] is None or availability[1] < 1:
raise WaitingListException(_('This product is currently not available.')) raise WaitingListException(_('This product is currently not available.'))
event = self.event
ev = self.subevent or self.event ev = self.subevent or self.event
if ev.seat_category_mappings.filter(product=self.item).exists(): if ev.seat_category_mappings.filter(product=self.item).exists():
# Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous # Generally, we advertise the waiting list to be based on quotas only. This makes it dangerous
@@ -185,44 +187,49 @@ class WaitingListEntry(LoggedModel):
if not free_seats: if not free_seats:
raise WaitingListException(_('No seat with this product is currently available.')) raise WaitingListException(_('No seat with this product is currently available.'))
if self.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.'))
if '@' not in self.email: if '@' not in self.email:
raise WaitingListException(_('This entry is anonymized and can no longer be used.')) raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
with transaction.atomic(): with transaction.atomic():
e = self.email locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
if self.name: locked_wle.event = event
e += ' / ' + self.name if locked_wle.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.'))
e = locked_wle.email
if locked_wle.name:
e += ' / ' + locked_wle.name
v = Voucher.objects.create( v = Voucher.objects.create(
event=self.event, event=locked_wle.event,
max_usages=1, max_usages=1,
valid_until=now() + timedelta(hours=self.event.settings.waiting_list_hours), valid_until=now() + timedelta(hours=locked_wle.event.settings.waiting_list_hours),
item=self.item, item=locked_wle.item,
variation=self.variation, variation=locked_wle.variation,
tag='waiting-list', tag='waiting-list',
comment=_('Automatically created from waiting list entry for {email}').format( comment=_('Automatically created from waiting list entry for {email}').format(
email=e email=e
), ),
block_quota=True, block_quota=True,
subevent=self.subevent, subevent=locked_wle.subevent,
) )
v.log_action('pretix.voucher.added', { v.log_action('pretix.voucher.added', {
'item': self.item.pk, 'item': locked_wle.item.pk,
'variation': self.variation.pk if self.variation else None, 'variation': locked_wle.variation.pk if locked_wle.variation else None,
'tag': 'waiting-list', 'tag': 'waiting-list',
'block_quota': True, 'block_quota': True,
'valid_until': v.valid_until.isoformat(), 'valid_until': v.valid_until.isoformat(),
'max_usages': 1, 'max_usages': 1,
'subevent': self.subevent.pk if self.subevent else None, 'subevent': locked_wle.subevent.pk if locked_wle.subevent else None,
'source': 'waitinglist', 'source': 'waitinglist',
}, user=user, auth=auth) }, user=user, auth=auth)
v.log_action('pretix.voucher.added.waitinglist', { v.log_action('pretix.voucher.added.waitinglist', {
'email': self.email, 'email': locked_wle.email,
'waitinglistentry': self.pk, 'waitinglistentry': locked_wle.pk,
}, user=user, auth=auth) }, user=user, auth=auth)
self.voucher = v locked_wle.voucher = v
self.save() locked_wle.save()
self.refresh_from_db()
self.event = event
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
self.send_mail( self.send_mail(
@@ -265,34 +272,30 @@ class WaitingListEntry(LoggedModel):
with language(self.locale, self.event.settings.region): with language(self.locale, self.event.settings.region):
recipient = self.email recipient = self.email
try: email_content = render_mail(template, context)
email_content = render_mail(template, context) subject = format_map(subject, context)
subject = format_map(subject, context) mail(
mail( recipient, subject, template, context,
recipient, subject, template, context, self.event,
self.event, self.locale,
self.locale, headers=headers,
headers=headers, sender=sender,
sender=sender, auto_email=auto_email,
auto_email=auto_email, attach_other_files=attach_other_files,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
attach_cached_files=attach_cached_files, )
) self.log_action(
except SendMailException: log_entry_type,
raise user=user,
else: auth=auth,
self.log_action( data={
log_entry_type, 'subject': subject,
user=user, 'message': email_content,
auth=auth, 'recipient': recipient,
data={ 'attach_other_files': attach_other_files,
'subject': subject, 'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
'message': email_content, }
'recipient': recipient, )
'attach_other_files': attach_other_files,
'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
}
)
@staticmethod @staticmethod
def clean_itemvar(event, item, variation): def clean_itemvar(event, item, variation):
+2 -2
View File
@@ -1231,8 +1231,8 @@ class ManualPayment(BasePaymentProvider):
def is_allowed(self, request: HttpRequest, total: Decimal=None): def is_allowed(self, request: HttpRequest, total: Decimal=None):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total) return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
def order_change_allowed(self, order: Order): def order_change_allowed(self, order: Order, request=None):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order) return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order, request)
@property @property
def public_name(self): def public_name(self):
+20 -29
View File
@@ -36,7 +36,7 @@ from pretix.base.models import (
SubEvent, TaxRule, User, WaitingListEntry, SubEvent, TaxRule, User, WaitingListEntry,
) )
from pretix.base.services.locking import LockTimeoutException from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import mail
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund, OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
) )
@@ -53,17 +53,14 @@ logger = logging.getLogger(__name__)
def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent): def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent):
with language(wle.locale, wle.event.settings.region): with language(wle.locale, wle.event.settings.region):
email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event) email_context = get_email_context(event_or_subevent=subevent or wle.event, event=wle.event)
try: mail(
mail( wle.email,
wle.email, format_map(subject, email_context),
format_map(subject, email_context), message,
message, email_context,
email_context, wle.event,
wle.event, locale=wle.locale
locale=wle.locale )
)
except SendMailException:
logger.exception('Waiting list canceled email could not be sent')
def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent, def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, subevent: SubEvent,
@@ -77,14 +74,11 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount, email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event) order=order, position_or_address=ia, event=order.event)
real_subject = format_map(subject, email_context) real_subject = format_map(subject, email_context)
try: order.send_mail(
order.send_mail( real_subject, message, email_context,
real_subject, message, email_context, 'pretix.event.order.email.event_canceled',
'pretix.event.order.email.event_canceled', user,
user, )
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in positions: for p in positions:
if subevent and p.subevent_id != subevent.id: if subevent and p.subevent_id != subevent.id:
@@ -97,15 +91,12 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
refund_amount=refund_amount, refund_amount=refund_amount,
position_or_address=p, position_or_address=p,
order=order, position=p) order=order, position=p)
try: order.send_mail(
order.send_mail( real_subject, message, email_context,
real_subject, message, email_context, 'pretix.event.order.email.event_canceled',
'pretix.event.order.email.event_canceled', position=p,
position=p, user=user
user=user )
)
except SendMailException:
logger.exception('Order canceled email could not be sent to attendee')
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
+3 -3
View File
@@ -1528,7 +1528,7 @@ class CartManager:
self._sales_channel.identifier, self._sales_channel.identifier,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions for cp in positions
] ]
) )
@@ -1639,7 +1639,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
if fee.tax_rule and not fee.tax_rule.pk: if fee.tax_rule and not fee.tax_rule.pk:
fee.tax_rule = None # TODO: deprecate fee.tax_rule = None # TODO: deprecate
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
if total != 0 and payments: if total != 0 and payments:
@@ -1679,7 +1679,7 @@ def get_fees(event, request, _total_ignored_=None, invoice_address=None, payment
fees.append(pf) fees.append(pf)
# Re-apply rounding as grand total has changed # Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, invoice_address, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed # Re-calculate to_pay as grand total has changed
+14 -2
View File
@@ -23,11 +23,12 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.db.models import Exists, OuterRef
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.models import CachedCombinedTicket, CachedTicket from pretix.base.models import CachedCombinedTicket, CachedTicket, OutgoingMail
from pretix.base.models.customers import CustomerSSOGrant from pretix.base.models.customers import CustomerSSOGrant
from ..models import CachedFile, CartPosition, InvoiceAddress from ..models import CachedFile, CartPosition, InvoiceAddress
@@ -49,7 +50,18 @@ def clean_cart_positions(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled() @scopes_disabled()
def clean_cached_files(sender, **kwargs): def clean_cached_files(sender, **kwargs):
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()): has_queued_email = Exists(
OutgoingMail.objects.filter(
should_attach_cached_files__pk=OuterRef("pk"),
status__in=(
OutgoingMail.STATUS_QUEUED,
OutgoingMail.STATUS_INFLIGHT,
OutgoingMail.STATUS_AWAITING_RETRY,
OutgoingMail.STATUS_FAILED,
),
)
)
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()).exclude(has_queued_email):
cf.delete() cf.delete()
+1 -1
View File
@@ -121,7 +121,7 @@ class CrossSellingService:
self.sales_channel, self.sales_channel,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled, cp.addon_to, cp.is_bundled,
cp.listed_price - cp.price_after_voucher) cp.listed_price - cp.price_after_voucher)
for cp in self.cartpositions for cp in self.cartpositions
], ],
+13 -2
View File
@@ -521,9 +521,20 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order): def invoice_qualified(order: Order):
if order.total == Decimal('0.00') or order.require_approval or \ if order.total == Decimal('0.00'):
order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
return False return False
if order.require_approval:
return False
if order.sales_channel.identifier not in order.event.settings.invoice_generate_sales_channels:
return False
if order.status in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED):
return False
if order.event.settings.invoice_generate_only_business:
try:
ia = order.invoice_address
return ia.is_business
except InvoiceAddress.DoesNotExist:
return False
return True return True
File diff suppressed because it is too large Load Diff
+23 -9
View File
@@ -19,6 +19,8 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # 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/>. # <https://www.gnu.org/licenses/>.
# #
import uuid
import css_inline import css_inline
from django.conf import settings from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
@@ -26,7 +28,9 @@ from django.utils.timezone import override
from django_scopes import scope, scopes_disabled from django_scopes import scope, scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import LogEntry, NotificationSetting, User from pretix.base.models import (
LogEntry, NotificationSetting, OutgoingMail, User,
)
from pretix.base.notifications import Notification, get_all_notification_types from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.mail import mail_send_task from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
@@ -153,16 +157,26 @@ def send_notification_mail(notification: Notification, user: User):
tpl_plain = get_template('pretixbase/email/notification.txt') tpl_plain = get_template('pretixbase/email/notification.txt')
body_plain = tpl_plain.render(ctx) body_plain = tpl_plain.render(ctx)
mail_send_task.apply_async(kwargs={ guid = uuid.uuid4()
'to': [user.email], m = OutgoingMail.objects.create(
'subject': '[{}] {}: {}'.format( guid=guid,
user=user,
to=[user.email],
subject='[{}] {}: {}'.format(
settings.PRETIX_INSTANCE_NAME, settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(), notification.event.settings.mail_prefix or notification.event.slug.upper(),
notification.title notification.title
), ),
'body': body_plain, body_plain=body_plain,
'html': body_html, body_html=body_html,
'sender': settings.MAIL_FROM_NOTIFICATIONS, sender=settings.MAIL_FROM_NOTIFICATIONS,
'headers': {}, headers={
'user': user.pk 'X-Auto-Response-Suppress': 'OOF, NRN, AutoReply, RN',
'Auto-Submitted': 'auto-generated',
'X-Mailer': 'pretix',
'X-PX-Correlation': str(guid),
},
)
mail_send_task.apply_async(kwargs={
'outgoing_mail': m.pk,
}) })
+137 -145
View File
@@ -90,7 +90,6 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import ( from pretix.base.services.locking import (
LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects, LOCK_TRUST_WINDOW, LockTimeoutException, lock_objects,
) )
from pretix.base.services.mail import SendMailException
from pretix.base.services.memberships import ( from pretix.base.services.memberships import (
create_membership, validate_memberships_in_order, create_membership, validate_memberships_in_order,
) )
@@ -438,33 +437,27 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee email_attendee_subject = order.event.settings.mail_subject_order_approved_attendee
email_context = get_email_context(event=order.event, order=order) email_context = get_email_context(event=order.event, order=order)
try: order.send_mail(
order.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.order_approved', user,
'pretix.event.order.email.order_approved', user, attach_tickets=True,
attach_tickets=True, attach_ical=order.event.settings.mail_attach_ical and (
attach_ical=order.event.settings.mail_attach_ical and ( not order.event.settings.mail_attach_ical_paid_only or
not order.event.settings.mail_attach_ical_paid_only or order.total == Decimal('0.00') or
order.total == Decimal('0.00') or order.valid_if_pending
order.valid_if_pending ),
), invoices=[invoice] if invoice and transmit_invoice_mail else []
invoices=[invoice] if invoice and transmit_invoice_mail else [] )
)
except SendMailException:
logger.exception('Order approved email could not be sent')
if email_attendees: if email_attendees:
for p in order.positions.all(): for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
email_attendee_context = get_email_context(event=order.event, order=order, position=p) email_attendee_context = get_email_context(event=order.event, order=order, position=p)
try: p.send_mail(
p.send_mail( email_attendee_subject, email_attendee_template, email_attendee_context,
email_attendee_subject, email_attendee_template, email_attendee_context, 'pretix.event.order.email.order_approved', user,
'pretix.event.order.email.order_approved', user, attach_tickets=True,
attach_tickets=True, )
)
except SendMailException:
logger.exception('Order approved email could not be sent to attendee')
return order.pk return order.pk
@@ -501,13 +494,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
email_template = order.event.settings.mail_text_order_denied email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment) email_context = get_email_context(event=order.event, order=order, comment=comment)
try: order.send_mail(
order.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.order_denied', user
'pretix.event.order.email.order_denied', user )
)
except SendMailException:
logger.exception('Order denied email could not be sent')
return order.pk return order.pk
@@ -660,14 +650,11 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
email_template = order.event.settings.mail_text_order_canceled email_template = order.event.settings.mail_text_order_canceled
email_subject = order.event.settings.mail_subject_order_canceled email_subject = order.event.settings.mail_subject_order_canceled
email_context = get_email_context(event=order.event, order=order, comment=comment or "") email_context = get_email_context(event=order.event, order=order, comment=comment or "")
try: order.send_mail(
order.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.order_canceled', user,
'pretix.event.order.email.order_canceled', user, invoices=transmit_invoices_mail,
invoices=transmit_invoices_mail, )
)
except SendMailException:
logger.exception('Order canceled email could not be sent')
for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)): for p in order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING)):
try: try:
@@ -914,7 +901,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
sales_channel.identifier, sales_channel.identifier,
[ [
(cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross, (cp.item_id, cp.subevent_id, cp.subevent.date_from if cp.subevent_id else None, cp.line_price_gross,
bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) cp.addon_to, cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions for cp in sorted_positions
] ]
) )
@@ -968,7 +955,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
fee.tax_rule = None # TODO: deprecate fee.tax_rule = None # TODO: deprecate
# Apply rounding to get final total in case no payment fees will be added # Apply rounding to get final total in case no payment fees will be added
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
payments_assigned = Decimal("0.00") payments_assigned = Decimal("0.00")
@@ -995,7 +982,7 @@ def _apply_rounding_and_fees(positions: List[CartPosition], payment_requests: Li
p['fee'] = pf p['fee'] = pf
# Re-apply rounding as grand total has changed # Re-apply rounding as grand total has changed
apply_rounding(event.settings.tax_rounding, event.currency, [*positions, *fees]) apply_rounding(event.settings.tax_rounding, address, event.currency, [*positions, *fees])
total = sum([c.price for c in positions]) + sum([f.value for f in fees]) total = sum([c.price for c in positions]) + sum([f.value for f in fees])
# Re-calculate to_pay as grand total has changed # Re-calculate to_pay as grand total has changed
@@ -1108,46 +1095,40 @@ def _order_placed_email(event: Event, order: Order, email_template, subject_temp
log_entry: str, invoice, payments: List[OrderPayment], is_free=False): log_entry: str, invoice, payments: List[OrderPayment], is_free=False):
email_context = get_email_context(event=event, order=order, payments=payments) email_context = get_email_context(event=event, order=order, payments=payments)
try: order.send_mail(
order.send_mail( subject_template, email_template, email_context,
subject_template, email_template, email_context, log_entry,
log_entry, invoices=[invoice] if invoice else [],
invoices=[invoice] if invoice else [], attach_tickets=True,
attach_tickets=True, attach_ical=event.settings.mail_attach_ical and (
attach_ical=event.settings.mail_attach_ical and ( not event.settings.mail_attach_ical_paid_only or
not event.settings.mail_attach_ical_paid_only or is_free or
is_free or order.valid_if_pending
order.valid_if_pending ),
), attach_other_files=[a for a in [
attach_other_files=[a for a in [ event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] ] if a],
] if a], )
)
except SendMailException:
logger.exception('Order received email could not be sent')
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template, def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, subject_template,
log_entry: str, is_free=False): log_entry: str, is_free=False):
email_context = get_email_context(event=event, order=order, position=position) email_context = get_email_context(event=event, order=order, position=position)
try: position.send_mail(
position.send_mail( subject_template, email_template, email_context,
subject_template, email_template, email_context, log_entry,
log_entry, invoices=[],
invoices=[], attach_tickets=True,
attach_tickets=True, attach_ical=event.settings.mail_attach_ical and (
attach_ical=event.settings.mail_attach_ical and ( not event.settings.mail_attach_ical_paid_only or
not event.settings.mail_attach_ical_paid_only or is_free or
is_free or order.valid_if_pending
order.valid_if_pending ),
), attach_other_files=[a for a in [
attach_other_files=[a for a in [ event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] ] if a],
] if a], )
)
except SendMailException:
logger.exception('Order received email could not be sent to attendee')
def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str], def _perform_order(event: Event, payment_requests: List[dict], position_ids: List[str],
@@ -1476,13 +1457,10 @@ def send_expiry_warnings(sender, **kwargs):
email_template = settings.mail_text_order_pending_warning email_template = settings.mail_text_order_pending_warning
email_subject = settings.mail_subject_order_pending_warning email_subject = settings.mail_subject_order_pending_warning
try: o.send_mail(
o.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.expire_warning_sent'
'pretix.event.order.email.expire_warning_sent' )
)
except SendMailException:
logger.exception('Reminder email could not be sent')
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@@ -1543,14 +1521,11 @@ def send_download_reminders(sender, **kwargs):
email_template = event.settings.mail_text_download_reminder email_template = event.settings.mail_text_download_reminder
email_subject = event.settings.mail_subject_download_reminder email_subject = event.settings.mail_subject_download_reminder
email_context = get_email_context(event=event, order=o) email_context = get_email_context(event=event, order=o)
try: o.send_mail(
o.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.download_reminder_sent',
'pretix.event.order.email.download_reminder_sent', attach_tickets=True
attach_tickets=True )
)
except SendMailException:
logger.exception('Reminder email could not be sent')
if event.settings.mail_send_download_reminder_attendee: if event.settings.mail_send_download_reminder_attendee:
for p in positions: for p in positions:
@@ -1564,14 +1539,11 @@ def send_download_reminders(sender, **kwargs):
email_template = event.settings.mail_text_download_reminder_attendee email_template = event.settings.mail_text_download_reminder_attendee
email_subject = event.settings.mail_subject_download_reminder_attendee email_subject = event.settings.mail_subject_download_reminder_attendee
email_context = get_email_context(event=event, order=o, position=p) email_context = get_email_context(event=event, order=o, position=p)
try: o.send_mail(
o.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.download_reminder_sent',
'pretix.event.order.email.download_reminder_sent', attach_tickets=True, position=p
attach_tickets=True, position=p )
)
except SendMailException:
logger.exception('Reminder email could not be sent to attendee')
def notify_user_changed_order(order, user=None, auth=None, invoices=[]): def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
@@ -1579,13 +1551,10 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
email_template = order.event.settings.mail_text_order_changed email_template = order.event.settings.mail_text_order_changed
email_context = get_email_context(event=order.event, order=order) email_context = get_email_context(event=order.event, order=order)
email_subject = order.event.settings.mail_subject_order_changed email_subject = order.event.settings.mail_subject_order_changed
try: order.send_mail(
order.send_mail( email_subject, email_template, email_context,
email_subject, email_template, email_context, 'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True,
'pretix.event.order.email.order_changed', user, auth=auth, invoices=invoices, attach_tickets=True, )
)
except SendMailException:
logger.exception('Order changed email could not be sent')
class OrderChangeManager: class OrderChangeManager:
@@ -1641,6 +1610,7 @@ class OrderChangeManager:
ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until')) ChangeValidUntilOperation = namedtuple('ChangeValidUntilOperation', ('position', 'valid_until'))
AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) AddBlockOperation = namedtuple('AddBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked')) RemoveBlockOperation = namedtuple('RemoveBlockOperation', ('position', 'block_name', 'ignore_from_quota_while_blocked'))
ForceRecomputeOperation = namedtuple('ForceRecomputeOperation', tuple())
class AddPositionResult: class AddPositionResult:
_position: Optional[OrderPosition] _position: Optional[OrderPosition]
@@ -1804,6 +1774,7 @@ class OrderChangeManager:
positions = self.order.positions.select_related('item', 'item__tax_rule') positions = self.order.positions.select_related('item', 'item__tax_rule')
ia = self._invoice_address ia = self._invoice_address
tax_rules = self._current_tax_rules() tax_rules = self._current_tax_rules()
self._operations.append(self.ForceRecomputeOperation())
for pos in positions: for pos in positions:
tax_rule = tax_rules.get(pos.pk, pos.tax_rule) tax_rule = tax_rules.get(pos.pk, pos.tax_rule)
@@ -2094,6 +2065,43 @@ class OrderChangeManager:
) )
item_counts[item] += 1 item_counts[item] += 1
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not a.item.all_sales_channels and
not a.item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
# "Re-select" add-on
selected_addons[cp.id, a.item.category_id][a.item_id, a.variation_id] += 1
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
# Check constraints on the add-on combinations # Check constraints on the add-on combinations
for op in toplevel_op: for op in toplevel_op:
item = op.item item = op.item
@@ -2126,41 +2134,6 @@ class OrderChangeManager:
} }
) )
# Detect removed add-ons and create RemoveOperations
for cp, al in list(current_addons.items()):
for k, v in al.items():
input_num = input_addons[cp.id].get(k, 0)
current_num = len(current_addons[cp].get(k, []))
if input_num < current_num:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
is_unavailable = (
# If an item is no longer available due to time, it should usually also be no longer
# user-removable, because e.g. the stock has already been ordered.
# We always pass has_voucher=True because if a product now requires a voucher, it usually does
# not mean it should be unremovable for others.
# This also prevents accidental removal through the UI because a hidden product will no longer
# be part of the input.
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and not a.variation.all_sales_channels and not a.variation.limit_sales_channels.contains(self.order.sales_channel))
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or (
not item.all_sales_channels and
not item.limit_sales_channels.contains(self.order.sales_channel)
)
)
if is_unavailable:
continue
if a.checkins.filter(list__consider_tickets_used=True).exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
item_counts[a.item] -= 1
for item, count in item_counts.items(): for item, count in item_counts.items():
if count == 0: if count == 0:
continue continue
@@ -2640,6 +2613,10 @@ class OrderChangeManager:
except BlockedTicketSecret.DoesNotExist: except BlockedTicketSecret.DoesNotExist:
pass pass
# todo: revoke list handling # todo: revoke list handling
elif isinstance(op, self.ForceRecomputeOperation):
self.order.log_action('pretix.event.order.changed.recomputed', user=self.user, auth=self.auth, data={})
else:
raise TypeError(f"Unknown operation {type(op)}")
for p in secret_dirty: for p in secret_dirty:
assign_ticket_secret( assign_ticket_secret(
@@ -2694,7 +2671,10 @@ class OrderChangeManager:
fees.append(new_fee) fees.append(new_fee)
changed_by_rounding = set(apply_rounding( changed_by_rounding = set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees self.order.tax_rounding_mode,
self._invoice_address,
self.event.currency,
[p for p in split_positions if not p.canceled] + fees
)) ))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) split_order.total = sum([p.price for p in split_positions if not p.canceled])
@@ -2716,7 +2696,10 @@ class OrderChangeManager:
fee.delete() fee.delete()
changed_by_rounding |= set(apply_rounding( changed_by_rounding |= set(apply_rounding(
self.order.tax_rounding_mode, self.event.currency, [p for p in split_positions if not p.canceled] + fees self.order.tax_rounding_mode,
self._invoice_address,
self.event.currency,
[p for p in split_positions if not p.canceled] + fees
)) ))
split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees]) split_order.total = sum([p.price for p in split_positions if not p.canceled]) + sum([f.value for f in fees])
@@ -2833,7 +2816,12 @@ class OrderChangeManager:
if fee_changed: if fee_changed:
fees = list(self.order.fees.all()) fees = list(self.order.fees.all())
changed = apply_rounding(self.order.tax_rounding_mode, self.order.event.currency, [*positions, *fees]) changed = apply_rounding(
self.order.tax_rounding_mode,
self._invoice_address,
self.order.event.currency,
[*positions, *fees]
)
for l in changed: for l in changed:
if isinstance(l, OrderPosition): if isinstance(l, OrderPosition):
l.save(update_fields=[ l.save(update_fields=[
@@ -3269,8 +3257,12 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
positions = list(order.positions.all()) positions = list(order.positions.all())
fees = list(order.fees.all()) fees = list(order.fees.all())
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
ia = None
rounding_changed = set(apply_rounding( rounding_changed = set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]] order.tax_rounding_mode, ia, order.event.currency, [*positions, *[f for f in fees if f.pk != fee.pk]]
)) ))
total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk) total_without_fee = sum(c.price for c in positions) + sum(f.value for f in fees if f.pk != fee.pk)
pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid) pending_sum_without_fee = max(Decimal("0.00"), total_without_fee - already_paid)
@@ -3295,7 +3287,7 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
fee = None fee = None
rounding_changed |= set(apply_rounding( rounding_changed |= set(apply_rounding(
order.tax_rounding_mode, order.event.currency, [*positions, *fees] order.tax_rounding_mode, ia, order.event.currency, [*positions, *fees]
)) ))
for l in rounding_changed: for l in rounding_changed:
if isinstance(l, OrderPosition): if isinstance(l, OrderPosition):
+14 -5
View File
@@ -174,7 +174,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
:param event: Event the cart belongs to :param event: Event the cart belongs to
:param sales_channel: Sales channel the cart was created with :param sales_channel: Sales channel the cart was created with
:param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` :param positions: Tuple of the form ``(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to_id, is_bundled, voucher_discount)``
``addon_to_id`` does not have to be the proper ID, any identifier is okay, even ``True``/``False`` are accepted, but
a better result may be given if addons to the same main product have the same distinct value.
:param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart :param collect_potential_discounts: If a `defaultdict(list)` is supplied, all discounts that could be applied to the cart
based on the "consumed" items, but lack matching "benefitting" items will be collected therein. based on the "consumed" items, but lack matching "benefitting" items will be collected therein.
The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list The dict will contain a mapping from index in the `positions` list of the item that could be consumed, to a list
@@ -196,9 +198,9 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs: for discount in discount_qs:
result = discount.apply({ result = discount.apply({
idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, voucher_discount) idx: PositionInfo(item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, voucher_discount)
for for
idx, (item_id, subevent_id, subevent_date_from, line_price_gross, is_addon_to, is_bundled, voucher_discount) idx, (item_id, subevent_id, subevent_date_from, line_price_gross, addon_to, is_bundled, voucher_discount)
in enumerate(positions) in enumerate(positions)
if not is_bundled and idx not in new_prices if not is_bundled and idx not in new_prices
}, collect_potential_discounts) }, collect_potential_discounts)
@@ -209,7 +211,8 @@ def apply_discounts(event: Event, sales_channel: Union[str, SalesChannel],
return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)] return [new_prices.get(idx, (p[3], None)) for idx, p in enumerate(positions)]
def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep_gross"], currency: str, def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_only_business", "sum_by_net_keep_gross"],
invoice_address: Optional[InvoiceAddress], currency: str,
lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list: lines: List[Union[OrderPosition, CartPosition, OrderFee]]) -> list:
""" """
Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode Given a list of order positions / cart positions / order fees (may be mixed), applies the given rounding mode
@@ -224,11 +227,17 @@ def apply_rounding(rounding_mode: Literal["line", "sum_by_net", "sum_by_net_keep
When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be When rounding mode is set to ``"sum_by_net"``, the gross prices and tax values of the individual lines will be
adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant. adjusted such that the per-taxrate/taxcode subtotal is rounded correctly. The net prices will stay constant.
:param rounding_mode: One of ``"line"``, ``"sum_by_net"``, or ``"sum_by_net_keep_gross"``. :param rounding_mode: One of ``"line"``, ``"sum_by_net"``, ``"sum_by_net_only_business"``, or ``"sum_by_net_keep_gross"``.
:param invoice_address: The invoice address, or ``None``
:param currency: Currency that will be used to determine rounding precision :param currency: Currency that will be used to determine rounding precision
:param lines: List of order/cart contents :param lines: List of order/cart contents
:return: Collection of ``lines`` members that have been changed and may need to be persisted to the database. :return: Collection of ``lines`` members that have been changed and may need to be persisted to the database.
""" """
if rounding_mode == "sum_by_net_only_business":
if invoice_address and invoice_address.is_business:
rounding_mode = "sum_by_net"
else:
rounding_mode = "line"
def _key(line): def _key(line):
return (line.tax_rate, line.tax_code or "") return (line.tax_rate, line.tax_code or "")
+16 -19
View File
@@ -48,7 +48,7 @@ from django.utils.translation import gettext_lazy as _
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, User, cachedfile_name from pretix.base.models import CachedFile, Event, User, cachedfile_name
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import mail
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.shredder import ShredError from pretix.base.shredder import ShredError
from pretix.celery_app import app from pretix.celery_app import app
@@ -171,21 +171,18 @@ def shred(self, event: Event, fileid: str, confirm_code: str, user: int=None, lo
if user: if user:
with language(user.locale): with language(user.locale):
try: mail(
mail( user.email,
user.email, _('Data shredding completed'),
_('Data shredding completed'), 'pretixbase/email/shred_completed.txt',
'pretixbase/email/shred_completed.txt', {
{ 'user': user,
'user': user, 'organizer': event.organizer.name,
'organizer': event.organizer.name, 'event': str(event.name),
'event': str(event.name), 'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'),
'start_time': date_format(parse(indexdata['time']).astimezone(event.timezone), 'SHORT_DATETIME_FORMAT'), 'shredders': ', '.join([str(s.verbose_name) for s in shredders])
'shredders': ', '.join([str(s.verbose_name) for s in shredders]) },
}, event=None,
event=None, user=user,
user=user, locale=user.locale,
locale=user.locale, )
)
except SendMailException:
pass # Already logged
+10 -1
View File
@@ -112,7 +112,8 @@ def dictsum(*dicts) -> dict:
def order_overview( def order_overview(
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False, event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None admission_only=False, base_qs=None, base_fees_qs=None, subevent_date_from=None, subevent_date_until=None,
skip_empty_lines=False,
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]: ) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related( items = event.items.all().select_related(
'category', # for re-grouping 'category', # for re-grouping
@@ -205,13 +206,21 @@ def order_overview(
for l in states.keys(): for l in states.keys():
var.num[l] = num[l].get((item.id, variid), (0, 0, 0)) var.num[l] = num[l].get((item.id, variid), (0, 0, 0))
var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0)) var.num['total'] = num['total'].get((item.id, variid), (0, 0, 0))
var._skip = all(v[0] == 0 for v in var.num.values())
for l in states.keys(): for l in states.keys():
item.num[l] = tuplesum(var.num[l] for var in item.all_variations) item.num[l] = tuplesum(var.num[l] for var in item.all_variations)
item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations) item.num['total'] = tuplesum(var.num['total'] for var in item.all_variations)
if skip_empty_lines:
item.all_variations = [v for v in item.all_variations if not v._skip]
item._skip = not item.all_variations
else: else:
for l in states.keys(): for l in states.keys():
item.num[l] = num[l].get((item.id, None), (0, 0, 0)) item.num[l] = num[l].get((item.id, None), (0, 0, 0))
item.num['total'] = num['total'].get((item.id, None), (0, 0, 0)) item.num['total'] = num['total'].get((item.id, None), (0, 0, 0))
item._skip = all(v[0] == 0 for v in item.num.values())
if skip_empty_lines:
items = [i for i in items if not i._skip]
nonecat = ItemCategory(name=_('Uncategorized')) nonecat = ItemCategory(name=_('Uncategorized'))
# Regroup those by category # Regroup those by category
+27 -2
View File
@@ -76,11 +76,12 @@ from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget, ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
) )
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries, pycountry_add
ROUNDING_MODES = ( ROUNDING_MODES = (
('line', _('Compute taxes for every line individually')), ('line', _('Compute taxes for every line individually')),
('sum_by_net', _('Compute taxes based on net total')), ('sum_by_net', _('Compute taxes based on net total')),
('sum_by_net_only_business', _('For business customers, compute taxes based on net total. For individuals, use line-based rounding')),
('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')), ('sum_by_net_keep_gross', _('Compute taxes based on net total with stable gross prices')),
# We could also have sum_by_gross, but we're not aware of any use-cases for it # We could also have sum_by_gross, but we're not aware of any use-cases for it
) )
@@ -180,6 +181,19 @@ DEFAULTS = {
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}), widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
) )
}, },
'customer_accounts_require_login_for_order_access': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require login to access order confirmation pages"),
help_text=_("If enabled, users who were logged in at the time of purchase must also log in to access their order information. "
"If a customer account is created while placing an order, the restriction only becomes active after the customer "
"account is activated."),
widget=forms.CheckboxInput(attrs={'data-display-dependency': '#id_settings-customer_accounts'}),
)
},
'customer_accounts_link_by_email': { 'customer_accounts_link_by_email': {
'default': 'False', 'default': 'False',
'type': bool, 'type': bool,
@@ -1212,6 +1226,15 @@ DEFAULTS = {
'default': json.dumps(['web']), 'default': json.dumps(['web']),
'type': list 'type': list
}, },
'invoice_generate_only_business': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Only issue invoices to business customers"),
)
},
'invoice_address_from': { 'invoice_address_from': {
'default': '', 'default': '',
'type': str, 'type': str,
@@ -3914,7 +3937,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'MX': (['State', 'Federal district', 'Federal entity'], 'short'), 'MX': (['State', 'Federal district', 'Federal entity'], 'short'),
'US': (['State', 'Outlying area', 'District'], 'short'), 'US': (['State', 'Outlying area', 'District'], 'short'),
'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province', 'IT': (['Province', 'Free municipal consortium', 'Metropolitan city', 'Autonomous province',
'Free municipal consortium', 'Decentralized regional entity'], 'short'), 'Decentralized regional entity'], 'short'),
} }
COUNTRY_STATE_LABEL = { COUNTRY_STATE_LABEL = {
# Countries in which the "State" field should not be called "State" # Countries in which the "State" field should not be called "State"
@@ -3922,6 +3945,8 @@ COUNTRY_STATE_LABEL = {
'JP': pgettext_lazy('address', 'Prefecture'), 'JP': pgettext_lazy('address', 'Prefecture'),
'IT': pgettext_lazy('address', 'Province'), 'IT': pgettext_lazy('address', 'Province'),
} }
# Workaround for https://github.com/pretix/pretix/issues/5796
pycountry_add(pycountry.subdivisions, code="IT-AO", country_code="IT", name="Valle d'Aosta", parent="23", parent_code="IT-23", type="Province")
settings_hierarkey = Hierarkey(attribute_name='settings') settings_hierarkey = Hierarkey(attribute_name='settings')
+5 -1
View File
@@ -51,7 +51,7 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.i18n import LazyLocaleException from pretix.base.i18n import LazyLocaleException
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment, CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, OrderPayment,
OrderPosition, OrderRefund, QuestionAnswer, OrderPosition, OrderRefund, OutgoingMail, QuestionAnswer,
) )
from pretix.base.services.invoices import invoice_pdf_task from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.signals import register_data_shredders from pretix.base.signals import register_data_shredders
@@ -329,6 +329,10 @@ class EmailAddressShredder(BaseDataShredder):
sleep_time=2, sleep_time=2,
) )
slow_delete(
OutgoingMail.objects.filter(event=self.event)
)
for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total): for o in _progress_helper(qs_orders, progress_callback, qs_op_cnt, total):
changed = bool(o.email) or bool(o.customer) changed = bool(o.email) or bool(o.customer)
o.email = None o.email = None
+10 -2
View File
@@ -944,32 +944,40 @@ As with all event-plugin signals, the ``sender`` keyword argument will contain t
email_filter = EventPluginSignal() email_filter = EventPluginSignal()
""" """
Arguments: ``message``, ``order``, ``user`` Arguments: ``message``, ``order``, ``user``, ``outgoing_mail``
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
return a (possibly modified) copy of the message object passed to you. return a (possibly modified) copy of the message object passed to you.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
The ``message`` argument will contain an ``EmailMultiAlternatives`` object. The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
might have newer information if a previous plugin already modified the email.
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
it will be ``None``. it will be ``None``.
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
well, otherwise it will be ``None``. well, otherwise it will be ``None``.
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
""" """
global_email_filter = GlobalSignal() global_email_filter = GlobalSignal()
""" """
Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer`` Arguments: ``message``, ``order``, ``user``, ``customer``, ``organizer``, ``outgoing_mail``
This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to This signal allows you to implement a middleware-style filter on all outgoing emails. You are expected to
return a (possibly modified) copy of the message object passed to you. return a (possibly modified) copy of the message object passed to you.
This signal is called on all events and even if there is no known event. ``sender`` is an event or None. This signal is called on all events and even if there is no known event. ``sender`` is an event or None.
The ``message`` argument will contain an ``EmailMultiAlternatives`` object. The ``message`` argument will contain an ``EmailMultiAlternatives`` object.
The ``outgoing_mail`` argument will contain the ``OutgoingMail`` model instance. Note that the ``message`` object
might have newer information if a previous plugin already modified the email.
If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise If the email is associated with a specific order, the ``order`` argument will be passed as well, otherwise
it will be ``None``. it will be ``None``.
If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as If the email is associated with a specific user, e.g. a notification email, the ``user`` argument will be passed as
well, otherwise it will be ``None``. well, otherwise it will be ``None``.
You can raise ``WithholdMailException`` to prevent the email from being sent, e.g. when implementing rate limiting.
""" """
+6 -8
View File
@@ -26,7 +26,8 @@ from babel.numbers import format_currency
from django import template from django import template
from django.conf import settings from django.conf import settings
from django.template.defaultfilters import floatformat from django.template.defaultfilters import floatformat
from django.utils import translation
from pretix.base.i18n import get_babel_locale
register = template.Library() register = template.Library()
@@ -59,13 +60,10 @@ def money_filter(value: Decimal, arg='', hide_currency=False):
if hide_currency: if hide_currency:
return floatformat(value, f"{places}g") return floatformat(value, f"{places}g")
locale_parts = translation.get_language().split("-", 1) try:
locale = locale_parts[0] locale = Locale(get_babel_locale())
if len(locale_parts) > 1 and len(locale_parts[1]) == 2: except UnknownLocaleError:
try: locale = "en"
locale = Locale(locale_parts[0], locale_parts[1].upper())
except UnknownLocaleError:
pass
try: try:
return format_currency(value, arg, locale=locale) return format_currency(value, arg, locale=locale)
+1 -1
View File
@@ -156,7 +156,7 @@ def safelink_callback(attrs, new=False):
Makes sure that all links to a different domain are passed through a redirection handler Makes sure that all links to a different domain are passed through a redirection handler
to ensure there's no passing of referers with secrets inside them. to ensure there's no passing of referers with secrets inside them.
""" """
url = attrs.get((None, 'href'), '/') url = html.unescape(attrs.get((None, 'href'), '/'))
if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'): if not url_has_allowed_host_and_scheme(url, allowed_hosts=None) and not url.startswith('mailto:') and not url.startswith('tel:'):
signer = signing.Signer(salt='safe-redirect') signer = signing.Signer(salt='safe-redirect')
attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url)) attrs[None, 'href'] = reverse('redirect') + '?url=' + urllib.parse.quote(signer.sign(url))
+2 -2
View File
@@ -82,7 +82,7 @@ def _info(cc):
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types] statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
return { return {
'data': [ 'data': [
{'name': s.name, 'code': s.code[3:]} {'name': gettext(s.name), 'code': s.code[3:]}
for s in sorted(statelist, key=lambda s: s.name) for s in sorted(statelist, key=lambda s: s.name)
], ],
**info, **info,
@@ -109,7 +109,7 @@ def address_form(request):
for t in get_transmission_types(): for t in get_transmission_types():
if t.is_available(event=event, country=country, is_business=is_business): if t.is_available(event=event, country=country, is_business=is_business):
result = {"name": str(t.public_name), "code": t.identifier} result = {"name": str(t.public_name), "code": t.identifier}
if t.exclusive: if t.is_exclusive(event=event, country=country, is_business=is_business):
info["transmission_types"] = [result] info["transmission_types"] = [result]
break break
else: else:
+6
View File
@@ -867,6 +867,11 @@ class TaxSettingsForm(EventSettingsValidationMixin, SettingsForm):
"The gross price of some products may be changed to ensure correct rounding, while the net " "The gross price of some products may be changed to ensure correct rounding, while the net "
"prices will be kept as configured. This may cause the actual payment amount to differ." "prices will be kept as configured. This may cause the actual payment amount to differ."
), ),
"sum_by_net_only_business": _(
"Same as above, but only applied to business customers. Line-based rounding will be used for consumers. "
"Recommended when e-invoicing is only used for business customers and consumers do not receive "
"invoices. This can cause the payment amount to change when the invoice address is changed."
),
"sum_by_net_keep_gross": _( "sum_by_net_keep_gross": _(
"Recommended for e-invoicing when you primarily sell to consumers. " "Recommended for e-invoicing when you primarily sell to consumers. "
"The gross or net price of some products may be changed automatically to ensure correct " "The gross or net price of some products may be changed automatically to ensure correct "
@@ -939,6 +944,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
'invoice_show_payments', 'invoice_show_payments',
'invoice_reissue_after_modify', 'invoice_reissue_after_modify',
'invoice_generate', 'invoice_generate',
'invoice_generate_only_business',
'invoice_period', 'invoice_period',
'invoice_attendee_name', 'invoice_attendee_name',
'invoice_event_location', 'invoice_event_location',
+65 -6
View File
@@ -57,8 +57,9 @@ from pretix.base.forms.widgets import (
from pretix.base.models import ( from pretix.base.models import (
Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue, Checkin, CheckinList, Device, Event, EventMetaProperty, EventMetaValue,
Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition, Gate, Invoice, InvoiceAddress, Item, Order, OrderPayment, OrderPosition,
OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel, OrderRefund, Organizer, OutgoingMail, Question, QuestionAnswer, Quota,
SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher, SalesChannel, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite,
Voucher,
) )
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.base.timeframes import ( from pretix.base.timeframes import (
@@ -1315,10 +1316,10 @@ class QuestionAnswerFilterForm(forms.Form):
if date_range is not None: if date_range is not None:
d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone) d_start, d_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), date_range, self.event.timezone)
opqs = opqs.filter( if d_start:
subevent__date_from__gte=d_start, opqs = opqs.filter(subevent__date_from__gte=d_start)
subevent__date_from__lt=d_end if d_end:
) opqs = opqs.filter(subevent__date_from__lt=d_end)
s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID) s = fdata.get("status", Order.STATUS_PENDING + Order.STATUS_PAID)
if s != "": if s != "":
@@ -2815,3 +2816,61 @@ class DeviceFilterForm(FilterForm):
qs = qs.order_by('-device_id') qs = qs.order_by('-device_id')
return qs return qs
class OutgoingMailFilterForm(FilterForm):
orders = {
'date': 'd',
'-date': '-d',
}
query = forms.CharField(
label=_('Search email address or subject'),
widget=forms.TextInput(attrs={
'placeholder': _('Search email address or subject'),
}),
required=False
)
event = forms.ModelChoiceField(
queryset=Event.objects.none(),
label=_('Event'),
empty_label=_('All events'),
required=False,
)
status = forms.ChoiceField(
label=_('Status'),
choices=[
('', _('All')),
*OutgoingMail.STATUS_CHOICES,
],
required=False
)
def __init__(self, *args, **kwargs):
request = kwargs.pop('request')
super().__init__(*args, **kwargs)
self.fields['event'].queryset = request.organizer.events.all()
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(to__containsstring=query.lower())
| Q(cc__containsstring=query.lower())
| Q(bcc__containsstring=query.lower())
| Q(subject__icontains=query)
)
if fdata.get('event'):
qs = qs.filter(event=fdata['event'])
if fdata.get('status'):
qs = qs.filter(status=fdata['status'])
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:
qs = qs.order_by("-created", "-pk")
return qs
+2
View File
@@ -474,6 +474,7 @@ class OrganizerSettingsForm(SettingsForm):
'customer_accounts', 'customer_accounts',
'customer_accounts_native', 'customer_accounts_native',
'customer_accounts_link_by_email', 'customer_accounts_link_by_email',
'customer_accounts_require_login_for_order_access',
'invoice_regenerate_allowed', 'invoice_regenerate_allowed',
'contact_mail', 'contact_mail',
'imprint_url', 'imprint_url',
@@ -584,6 +585,7 @@ class MailSettingsForm(SettingsForm):
help_text=''.join([ help_text=''.join([
str(_("All emails will be sent to this address as a Bcc copy.")), str(_("All emails will be sent to this address as a Bcc copy.")),
str(_("You can specify multiple recipients separated by commas.")), str(_("You can specify multiple recipients separated by commas.")),
str(_("Sensitive emails like password resets will not be sent in Bcc.")),
]), ]),
validators=[multimail_validate], validators=[multimail_validate],
required=False, required=False,
+8
View File
@@ -170,6 +170,12 @@ class OrderFeeAdded(OrderChangeLogEntryType):
plain = _('A fee has been added') plain = _('A fee has been added')
@log_entry_types.new()
class OrderRecomputed(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.recomputed'
plain = _('Taxes and rounding have been recomputed')
@log_entry_types.new() @log_entry_types.new()
class OrderFeeChanged(OrderChangeLogEntryType): class OrderFeeChanged(OrderChangeLogEntryType):
action_type = 'pretix.event.order.changed.feevalue' action_type = 'pretix.event.order.changed.feevalue'
@@ -699,6 +705,8 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'), 'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'), 'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'), 'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.organizer.outgoingmails.retried': _('Failed emails have been scheduled to be retried.'),
'pretix.organizer.outgoingmails.aborted': _('Queued emails have been aborted.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'), 'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'), 'pretix.giftcards.acceptance.acceptor.invited': _('A new gift card acceptor has been invited.'),
+9
View File
@@ -679,6 +679,15 @@ def get_organizer_navigation(request):
'active': (url.url_name == 'organizer.datasync.failedjobs'), 'active': (url.url_name == 'organizer.datasync.failedjobs'),
}]) }])
nav.append({
'label': _('Outgoing emails'),
'url': reverse('control:organizer.outgoingmails', kwargs={
'organizer': request.organizer.slug,
}),
'active': 'organizer.outgoingmail' in url.url_name,
'icon': 'send',
})
merge_in(nav, sorted( merge_in(nav, sorted(
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)), sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
[]), []),
@@ -12,6 +12,7 @@
<legend>{% trans "Invoice generation" %}</legend> <legend>{% trans "Invoice generation" %}</legend>
{% bootstrap_field form.invoice_generate layout="control" %} {% bootstrap_field form.invoice_generate layout="control" %}
{% bootstrap_field form.invoice_generate_sales_channels layout="control" %} {% bootstrap_field form.invoice_generate_sales_channels layout="control" %}
{% bootstrap_field form.invoice_generate_only_business layout="control" %}
{% bootstrap_field form.invoice_email_attachment layout="control" %} {% bootstrap_field form.invoice_email_attachment layout="control" %}
{% bootstrap_field form.invoice_email_organizer layout="control" %} {% bootstrap_field form.invoice_email_organizer layout="control" %}
{% bootstrap_field form.invoice_language layout="control" %} {% bootstrap_field form.invoice_language layout="control" %}
@@ -111,11 +112,6 @@
<span class="text-success"> <span class="text-success">
<span class="fa fa-check fa-fw"></span> <span class="fa fa-check fa-fw"></span>
{% trans "Available" %} {% trans "Available" %}
{% if t.exclusive %}
<span data-toggle="tooltip" title="{% trans "When this type is available for an invoice address, no other type can be selected." %}">
{% trans "(exclusive)" %}
</span>
{% endif %}
</span> </span>
{% else %} {% else %}
<span class="text-muted"> <span class="text-muted">
@@ -353,7 +353,7 @@
data-toggle="tooltip" data-toggle="tooltip"
title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}" title="{% trans 'Generate a cancellation document for this invoice and create a new invoice with a new invoice number.' %}"
{% endif %}> {% endif %}>
{% if order.status == "c" %} {% if order.status == "c" or not invoice_qualified %}
{% trans "Generate cancellation" %} {% trans "Generate cancellation" %}
{% else %} {% else %}
{% trans "Cancel and reissue" %} {% trans "Cancel and reissue" %}
@@ -22,7 +22,7 @@
{{ s.owner.fullname|default:s.owner.email }} {{ s.owner.fullname|default:s.owner.email }}
</span> </span>
</div> </div>
<div class="col-lg-5 col-md-6 col-xs-12"> <div class="col-lg-4 col-md-5 col-xs-12">
{% if s.schedule_next_run %} {% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span> <span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %} {% trans "Next run:" %}
@@ -53,7 +53,7 @@
{{ s.mail_subject }} {{ s.mail_subject }}
</span> </span>
</div> </div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right"> <div class="col-lg-3 col-md-3 col-xs-12 text-right">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}" <form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long> data-asynctask-long>
@@ -73,6 +73,9 @@
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip"> <a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span> <span class="fa fa-edit"></span>
</a> </a>
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %} {% endif %}
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip"> <a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span> <span class="fa fa-trash"></span>
@@ -42,7 +42,11 @@
<div class="form-group submit-group"> <div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit" <button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask> class="btn btn-primary btn-save" data-no-asynctask>
{% trans "Save" %} {% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button> </button>
</div> </div>
{% else %} {% else %}
@@ -132,6 +132,7 @@
<legend>{% trans "Customer accounts" %}</legend> <legend>{% trans "Customer accounts" %}</legend>
{% bootstrap_field sform.customer_accounts layout="control" %} {% bootstrap_field sform.customer_accounts layout="control" %}
{% bootstrap_field sform.customer_accounts_native layout="control" %} {% bootstrap_field sform.customer_accounts_native layout="control" %}
{% bootstrap_field sform.customer_accounts_require_login_for_order_access layout="control" %}
{% bootstrap_field sform.customer_accounts_link_by_email layout="control" %} {% bootstrap_field sform.customer_accounts_link_by_email layout="control" %}
{% bootstrap_field sform.name_scheme layout="control" %} {% bootstrap_field sform.name_scheme layout="control" %}
{% bootstrap_field sform.name_scheme_titles layout="control" %} {% bootstrap_field sform.name_scheme_titles layout="control" %}
@@ -22,7 +22,7 @@
{{ s.owner.fullname|default:s.owner.email }} {{ s.owner.fullname|default:s.owner.email }}
</span> </span>
</div> </div>
<div class="col-lg-5 col-md-6 col-xs-12"> <div class="col-lg-4 col-md-5 col-xs-12">
{% if s.schedule_next_run %} {% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span> <span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %} {% trans "Next run:" %}
@@ -53,7 +53,7 @@
{{ s.mail_subject }} {{ s.mail_subject }}
</span> </span>
</div> </div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right"> <div class="col-lg-3 col-md-3 col-xs-12 text-right">
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}" <form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long> data-asynctask-long>
@@ -73,6 +73,9 @@
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip"> <a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span> <span class="fa fa-edit"></span>
</a> </a>
<a href="?identifier={{ s.export_identifier }}&scheduled_copy_from={{ s.pk }}" class="btn btn-default" title="{% trans "Copy" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span>
</a>
{% endif %} {% endif %}
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip"> <a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span> <span class="fa fa-trash"></span>
@@ -43,7 +43,11 @@
<div class="form-group submit-group"> <div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit" <button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask> class="btn btn-primary btn-save" data-no-asynctask>
{% trans "Save" %} {% if scheduled_copy_from %}
{% trans "Save copy" %}
{% else %}
{% trans "Save" %}
{% endif %}
</button> </button>
</div> </div>
{% else %} {% else %}
@@ -0,0 +1,222 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% load icon %}
{% load compress %}
{% load static %}
{% block inner %}
<h1>
{% trans "Outgoing email" %}
</h1>
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Email details" %}</h3>
</div>
<div class="panel-body">
<div class="row">
<div class="col-lg-7 col-md-12">
<dl class="dl-horizontal">
<dt>{% trans "From" context "email" %}</dt>
<dd>{{ mail.sender }}</dd>
<dt>{% trans "To" context "email" %}</dt>
<dd>{{ mail.to|join:", " }}</dd>
{% if mail.cc %}
<dt>{% trans "Cc" context "email" %}</dt>
<dd>{{ mail.cc|join:", " }}</dd>
{% endif %}
{% if mail.bcc %}
<dt>{% trans "Bcc" context "email" %}</dt>
<dd>{{ mail.bcc|join:", " }}</dd>
{% endif %}
<dt>{% trans "Subject" %}</dt>
<dd>{{ mail.subject }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if mail.status == "queued" %}
<span class="label label-info">{% icon "clock-o" %} {% trans "queued" %}</span>
{% elif mail.status == "inflight" %}
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
{% elif mail.status == "awaiting_retry" %}
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
{% elif mail.status == "failed" %}
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
{% elif mail.status == "bounced" %}
<span class="label label-danger">{% icon "exclamation-circle" %} {% trans "bounced" %}</span>
{% elif mail.status == "withheld" %}
<span class="label label-warning">{% icon "ban" %} {% trans "withheld" %}</span>
{% elif mail.status == "aborted" %}
<span class="label label-danger">{% icon "ban" %} {% trans "aborted" %}</span>
{% elif mail.status == "sent" %}
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
{% endif %}
</dd>
<dt>{% trans "Creation" %}</dt>
<dd>{{ mail.created|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% if mail.sent %}
<dt>{% trans "Sent" %}</dt>
<dd>{{ mail.sent|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if mail.retry_after and mail.status == "awaiting_retry" %}
<dt>{% trans "Next attempt (estimate)" %}</dt>
<dd>{{ mail.retry_after|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %}
{% if mail.event %}
<dt>{% trans "Event" %}</dt>
<dd>
<a href="{% url "control:event.index" organizer=request.organizer.slug event=mail.event.slug %}">
{{ mail.event }}
</a>
</dd>
{% endif %}
{% if mail.order %}
<dt>{% trans "Order" %}</dt>
<dd>
<a href="{% url "control:event.order" organizer=request.organizer.slug event=mail.event.slug code=mail.order.code %}">
{{ mail.order.code }}</a>{% if mail.orderposition %}-
{{ mail.orderposition.positionid }}{% endif %}
</dd>
{% endif %}
{% if mail.customer %}
<dt>{% trans "Customer" %}</dt>
<dd>
{% icon "user fa-fw" %}
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=mail.customer.identifier %}">
{{ mail.customer }}
</a>
</dd>
{% endif %}
</dl>
</div>
{% if mail.actual_attachments %}
<div class="col-lg-5 col-md-12">
<strong>{% trans "Attachments" %}</strong><br>
<ul class="list-unstyled">
{% for a in mail.actual_attachments %}
<li>
{% if a.type == "text/calendar" %}
{% icon "calendar-plus-o fa-fw" %}
{% elif a.type == "application/pdf" %}
{% icon "file-pdf-o fa-fw" %}
{% elif "image/" in a.type %}
{% icon "file-image-o fa-fw" %}
{% elif "msword" in a.type or "document" in a.type %}
{% icon "file-word-o fa-fw" %}
{% elif "excel" in a.type or "spreadsheet" in a.type %}
{% icon "file-excel-o fa-fw" %}
{% elif "powerpoint" in a.type or "presentation" in a.type %}
{% icon "file-powerpoint-o fa-fw" %}
{% elif "pkpass" in a.type %}
{% icon "qrcode fa-fw" %}
{% else %}
{% icon "file-o fa-fw" %}
{% endif %}
{{ a.name }}
<span class="text-muted">
({{ a.size|filesizeformat }})
</span>
</li>
{% endfor %}
</ul>
</div>
{% endif %}
</div>
</div>
</div>
<div>
<ul class="nav nav-tabs" role="tablist">
{% if mail.is_failed %}
<li role="presentation" class="active">
<a href="#tab-error" role="tab" data-toggle="tab">
<span class="fa fa-warning"></span>
{% trans "Error" %}
</a>
</li>
{% endif %}
{% if mail.body_html %}
<li role="presentation"
{% if not mail.is_failed %}class="active"{% endif %}>
<a href="#tab-html" role="tab" data-toggle="tab">
<span class="fa fa-eye"></span>
{% trans "HTML content" %}
</a>
</li>
{% endif %}
<li role="presentation"
{% if not mail.is_failed and not mail.body_html %}class="active"{% endif %}>
<a href="#tab-text" role="tab" data-toggle="tab">
<span class="fa fa-file-text-o"></span>
{% trans "Text content" %}
</a>
</li>
<li role="presentation">
<a href="#tab-headers" role="tab" data-toggle="tab">
<span class="fa fa-code"></span>
{% trans "Headers" %}
</a>
</li>
</ul>
<div class="tab-content">
{% if mail.is_failed %}
<div role="tabpanel" class="tab-pane active" id="tab-error">
<strong>
{{ mail.error }}
</strong>
<pre>{{ mail.error_detail }}</pre>
</div>
{% endif %}
{% if mail.body_html %}
<div role="tabpanel"
class="tab-pane {% if not mail.is_failed %}active{% endif %}"
id="tab-html">
{% if mail.sensitive %}
<div class="empty-collection">
<p>
{% icon "eye-slash fa-4x" %}
</p>
<p>
{% blocktrans trimmed %}
Sensitive content not shown for security reasons
{% endblocktrans %}
</p>
</div>
{% else %}
{{ data_url|json_script:"mail_body_html" }}
{% endif %}
</div>
{% endif %}
<div role="tabpanel"
class="tab-pane {% if not mail.is_failed and not mail.body_html %}active{% endif %}"
id="tab-text">
{% if mail.sensitive %}
<div class="empty-collection">
<p>
{% icon "eye-slash fa-4x" %}
</p>
<p>
{% blocktrans trimmed %}
Sensitive content not shown for security reasons
{% endblocktrans %}
</p>
</div>
{% else %}
<pre><code>{{ mail.body_plain }}</code></pre>
{% endif %}
</div>
<div role="tabpanel"
class="tab-pane"
id="tab-headers">
<pre><code>{% for k, v in mail.headers.items %}{{ k }}: {{ v }}<br>{% endfor %}</code></pre>
<p class="text-muted">
{% trans "Additional headers will be added by the mail server and are not visible here." %}
</p>
</div>
</div>
</div>
{% compress js %}
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/outgoingmail.js" %}"></script>
{% endcompress %}
{% endblock %}
@@ -0,0 +1,185 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load urlreplace %}
{% load icon %}
{% block inner %}
<h1>
{% trans "Outgoing emails" %}
</h1>
<p>
{% blocktrans trimmed with days=days %}
This is an overview of all emails sent by your organizer account in the last {{ days }} days.
{% endblocktrans %}
</p>
{% if mails|length == 0 and not filter_form.filtered %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
You haven't sent any emails recently.
{% endblocktrans %}
</p>
</div>
{% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Filter" %}</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="col-md-4 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query %}
</div>
<div class="col-md-3 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.status %}
</div>
<div class="col-md-5 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.event %}
</div>
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
<form action="{% url "control:organizer.outgoingmails.bulk_action" organizer=request.organizer.slug %}" method="post">
{% csrf_token %}
{% for field in filter_form %}
{{ field.as_hidden }}
{% endfor %}
<div class="table-responsive">
<table class="table table-condensed table-hover table-quotas">
<thead>
<tr>
<th>
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
</th>
<th>{% trans "Subject" %}</th>
<th>{% trans "Recipients" %}</th>
<th>{% trans "Context" %}</th>
<th>{% trans "Status" %}</th>
<th>{% trans "Date" %}
<a href="?{% url_replace request 'ordering' '-date' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'date' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th></th>
</tr>
{% if page_obj.paginator.num_pages > 1 %}
<tr class="table-select-all warning hidden">
<td>
<input type="checkbox" name="__ALL" id="__all"
data-results-total="{{ page_obj.paginator.count }}">
</td>
<td colspan="7">
<label for="__all">
{% trans "Select all results on other pages as well" %}
</label>
</td>
</tr>
{% endif %}
</thead>
<tbody>
{% for m in mails %}
<tr>
<td>
<label aria-label="{% trans "select row for batch-operation" %}"
class="batch-select-label"><input type="checkbox" name="outgoingmail"
class="batch-select-checkbox"
value="{{ m.pk }}"/></label>
</td>
<td>
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}">
{{ m.subject }}
</a>
{% if m.sensitive %}
<span class="text-muted">{% icon "eye-slash" %}</span>
{% endif %}
</td>
<td>
{{ m.to|join:", " }}
{% if m.cc %}
<br><small class="text-muted">{% trans "Cc" context "email" %}: {{ m.cc|join:", " }}</small>
{% endif %}
{% if m.bcc %}
<br><small class="text-muted">{% trans "Bcc" context "email" %}: {{ m.bcc|join:", " }}</small>
{% endif %}
</td>
<td>
{% if m.event %}
<div>
{% icon "calendar fa-fw" %}
<a href="{% url "control:event.index" organizer=request.organizer.slug event=m.event.slug %}">
{{ m.event }}
</a>
</div>
{% endif %}
{% if m.order %}
<div>
{% icon "shopping-cart fa-fw" %}
<a href="{% url "control:event.order" organizer=request.organizer.slug event=m.event.slug code=m.order.code %}">
{{ m.order.code }}</a>{% if m.orderposition %}-{{ m.orderposition.positionid }}{% endif %}
</div>
{% endif %}
{% if m.customer %}
<div>
{% icon "user fa-fw" %}
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
{{ m.customer }}
</a>
</div>
{% endif %}
</td>
<td>
{% if m.status == "queued" %}
<span class="label label-info">{% icon "clock-o" %} {% trans "queued" %}</span>
{% elif m.status == "inflight" %}
<span class="label label-info">{% icon "send" %} {% trans "being sent" %}</span>
{% elif m.status == "awaiting_retry" %}
<span class="label label-warning">{% icon "repeat" %} {% trans "will be retried" %}</span>
{% elif m.status == "failed" %}
<span class="label label-danger">{% icon "warning" %} {% trans "failed" %}</span>
{% elif m.status == "bounced" %}
<span class="label label-danger">{% icon "exclamation-circle" %} {% trans "bounced" %}</span>
{% elif m.status == "withheld" %}
<span class="label label-warning">{% icon "ban" %} {% trans "withheld" %}</span>
{% elif m.status == "aborted" %}
<span class="label label-danger">{% icon "ban" %} {% trans "aborted" %}</span>
{% elif m.status == "sent" %}
<span class="label label-success">{% icon "check" %} {% trans "sent" %}</span>
{% endif %}
</td>
<td>
{{ m.created|date:"SHORT_DATETIME_FORMAT" }}
{% if m.sent %}
<br>
<small class="text-muted">{% trans "Sent:" %} {{ m.sent|date:"SHORT_DATETIME_FORMAT" }}</small>
{% endif %}
</td>
<td class="text-right flip">
<a href="{% url "control:organizer.outgoingmail" organizer=request.organizer.slug mail=m.id %}"
class="btn btn-default btn-sm">{% icon "eye" %}</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="batch-select-actions">
<button type="submit" class="btn btn-primary btn-save" name="action" value="retry">
{% icon "repeat" %}
{% trans "Retry (if failed or withheld)" %}
</button>
<button type="submit" class="btn btn-danger btn-save" name="action" value="abort">
{% icon "ban" %}
{% trans "Abort (if queued, awaiting retry or withheld)" %}
</button>
</div>
</form>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}
+6 -2
View File
@@ -38,8 +38,9 @@ from django.views.generic.base import RedirectView
from pretix.control.views import ( from pretix.control.views import (
auth, checkin, dashboards, datasync, discounts, event, geo, auth, checkin, dashboards, datasync, discounts, event, geo,
global_settings, item, main, modelimport, oauth, orders, organizer, pdf, global_settings, item, mail, main, modelimport, oauth, orders, organizer,
search, shredder, subevents, typeahead, user, users, vouchers, waitinglist, pdf, search, shredder, subevents, typeahead, user, users, vouchers,
waitinglist,
) )
urlpatterns = [ urlpatterns = [
@@ -240,6 +241,9 @@ urlpatterns = [
name='organizer.gate.edit'), name='organizer.gate.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/gate/(?P<gate>[^/]+)/delete$', organizer.GateDeleteView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/gate/(?P<gate>[^/]+)/delete$', organizer.GateDeleteView.as_view(),
name='organizer.gate.delete'), name='organizer.gate.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmails$', mail.OutgoingMailListView.as_view(), name='organizer.outgoingmails'),
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/bulk_action$', mail.OutgoingMailBulkAction.as_view(), name='organizer.outgoingmails.bulk_action'),
re_path(r'^organizer/(?P<organizer>[^/]+)/outgoingmail/(?P<mail>[0-9]+)/$', mail.OutgoingMailDetailView.as_view(), name='organizer.outgoingmail'),
re_path(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'), re_path(r'^organizer/(?P<organizer>[^/]+)/teams$', organizer.TeamListView.as_view(), name='organizer.teams'),
re_path(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'), re_path(r'^organizer/(?P<organizer>[^/]+)/team/add$', organizer.TeamCreateView.as_view(), name='organizer.team.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/team/(?P<team>[^/]+)/$', organizer.TeamMemberView.as_view(),
+6 -5
View File
@@ -57,6 +57,7 @@ from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_http_methods from django.views.decorators.http import require_http_methods
from django.views.generic import TemplateView from django.views.generic import TemplateView
from django_otp import match_token from django_otp import match_token
from django_otp.plugins.otp_static.models import StaticDevice
from webauthn.helpers import generate_challenge from webauthn.helpers import generate_challenge
from pretix.base.auth import get_auth_backends from pretix.base.auth import get_auth_backends
@@ -65,7 +66,6 @@ from pretix.base.forms.auth import (
) )
from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins from pretix.base.metrics import pretix_failed_logins, pretix_successful_logins
from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice from pretix.base.models import TeamInvite, U2FDevice, User, WebAuthnDevice
from pretix.base.services.mail import SendMailException
from pretix.helpers.http import get_client_ip, redirect_to_url from pretix.helpers.http import get_client_ip, redirect_to_url
from pretix.helpers.security import handle_login_source from pretix.helpers.security import handle_login_source
@@ -346,9 +346,6 @@ class Forgot(TemplateView):
except User.DoesNotExist: except User.DoesNotExist:
logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.') logger.warning('Backend password reset for unregistered e-mail \"' + email + '\" requested.')
except SendMailException:
logger.exception('Sending password reset email to \"' + email + '\" failed.')
except RepeatedResetDenied: except RepeatedResetDenied:
pass pass
@@ -363,7 +360,7 @@ class Forgot(TemplateView):
else: else:
messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.')) messages.info(request, _('If the address is registered to valid account, then we have sent you an email containing further instructions.'))
return redirect('control:auth.forgot') return redirect('control:auth.forgot')
else: else:
return self.get(request, *args, **kwargs) return self.get(request, *args, **kwargs)
@@ -538,6 +535,10 @@ class Login2FAView(TemplateView):
break break
else: else:
valid = match_token(self.user, token) valid = match_token(self.user, token)
if isinstance(valid, StaticDevice):
self.user.send_security_notice([
_("A recovery code for two-factor authentification was used to log in.")
])
if valid: if valid:
logger.info(f"Backend login successful for user {self.user.pk} with 2FA.") logger.info(f"Backend login successful for user {self.user.pk} with 2FA.")
@@ -188,6 +188,8 @@ class LicenseCheckView(StaffMemberRequiredMixin, FormView):
return None, None return None, None
try: try:
for k, v in pkg.metadata.items(): for k, v in pkg.metadata.items():
if k == "License-Expression":
license = v
if k == "License": if k == "License":
license = v license = v
if k == "Home-page": if k == "Home-page":
+186
View File
@@ -0,0 +1,186 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-today pretix GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import base64
import logging
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import BadRequest
from django.db import transaction
from django.shortcuts import get_object_or_404, redirect
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.translation import ngettext
from django.views import View
from django.views.generic import DetailView, ListView
from pretix.base.middleware import _merge_csp, _parse_csp, _render_csp
from pretix.base.models import OutgoingMail
from pretix.base.services.mail import mail_send_task
from pretix.control.forms.filter import OutgoingMailFilterForm
from pretix.control.permissions import OrganizerPermissionRequiredMixin
from pretix.control.views.organizer import OrganizerDetailViewMixin
logger = logging.getLogger(__name__)
class OutgoingMailQueryMixin:
@cached_property
def request_data(self):
if self.request.method == "POST":
d = self.request.POST
else:
d = self.request.GET
d = d.copy()
return d
@cached_property
def filter_form(self):
return OutgoingMailFilterForm(
data=self.request_data,
request=self.request,
)
def get_queryset(self):
qs = self.request.organizer.outgoing_mails.select_related(
'event', 'order', 'orderposition', 'customer'
)
if 'outgoingmail' in self.request_data and '__ALL' not in self.request_data:
qs = qs.filter(
id__in=self.request_data.getlist('outgoingmail')
)
elif self.request.method == 'GET' or '__ALL' in self.request_data:
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
else:
raise BadRequest("No mails selected")
return qs
class OutgoingMailListView(OutgoingMailQueryMixin, OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = OutgoingMail
template_name = 'pretixcontrol/organizers/outgoing_mails.html'
# Assume "the highest" permission level for now because emails could belog to any event, order, or customer.
# We plan to add a special permissoin in the future
permission = 'can_change_organizer_settings'
context_object_name = 'mails'
paginate_by = 100
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
ctx['days'] = int(settings.OUTGOING_MAIL_RETENTION / (24 * 3600))
return ctx
class OutgoingMailDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, DetailView):
model = OutgoingMail
template_name = 'pretixcontrol/organizers/outgoing_mail.html'
permission = 'can_change_organizer_settings'
context_object_name = 'mail'
def get_object(self, queryset=None):
return get_object_or_404(OutgoingMail, organizer=self.request.organizer, pk=self.kwargs.get('mail'))
def dispatch(self, request, *args, **kwargs):
response = super().dispatch(request, *args, **kwargs)
if 'Content-Security-Policy' in response:
h = _parse_csp(response['Content-Security-Policy'])
else:
h = {}
csps = {
'frame-src': ['data:'],
# Unfortuantely, we can't avoid unsafe-inline for style here.
# See outgoingmail.js for the protection measures we take.
'style-src': ["'unsafe-inline'"],
}
_merge_csp(h, csps)
response['Content-Security-Policy'] = _render_csp(h)
return response
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
if self.object.body_html:
ctx['data_url'] = "data:text/html;charset=utf-8;base64," + base64.b64encode(self.object.body_html.encode()).decode()
return ctx
class OutgoingMailBulkAction(OutgoingMailQueryMixin, OrganizerPermissionRequiredMixin, OrganizerDetailViewMixin, View):
permission = 'can_change_organizer_settings'
@transaction.atomic
def post(self, request, *args, **kwargs):
if request.POST.get('action') == 'retry':
ids = set(
self.get_queryset().filter(status__in=OutgoingMail.STATUS_LIST_RETRYABLE).values_list("pk", flat=True)
)
with transaction.atomic():
OutgoingMail.objects.filter(pk__in=ids).update(
status=OutgoingMail.STATUS_QUEUED,
sent=None,
)
self.request.organizer.log_action(
'pretix.organizer.outgoingmails.retried', user=self.request.user, data={
'mails': list(ids)
}, save=False
)
for i in ids:
mail_send_task.apply_async(kwargs={"outgoing_mail": i})
messages.success(request, ngettext(
"A retry of one email was scheduled.",
"A retry of {num} emails was scheduled.",
len(ids),
).format(num=len(ids)))
elif request.POST.get('action') == 'abort':
ids = set(
self.get_queryset().filter(
status__in=(OutgoingMail.STATUS_QUEUED, OutgoingMail.STATUS_AWAITING_RETRY)
).values_list("pk", flat=True)
)
with transaction.atomic():
OutgoingMail.objects.filter(pk__in=ids).update(
status=OutgoingMail.STATUS_ABORTED,
sent=None,
)
self.request.organizer.log_action(
'pretix.organizer.outgoingmails.aborted', user=self.request.user, data={
'mails': list(ids)
}, save=False
)
for i in ids:
mail_send_task.apply_async(kwargs={"outgoing_mail": i})
messages.success(request, ngettext(
"One email was aborted and will not be sent.",
"{num} emails were aborted and will not be sent.",
len(ids),
).format(num=len(ids)))
return redirect(self.get_success_url())
def get_success_url(self) -> str:
return reverse('control:organizer.outgoingmails', kwargs={
'organizer': self.request.organizer.slug,
})
+71 -78
View File
@@ -33,6 +33,7 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import copy
import json import json
import logging import logging
import mimetypes import mimetypes
@@ -97,9 +98,7 @@ from pretix.base.services.invoices import (
invoice_qualified, regenerate_invoice, transmit_invoice, invoice_qualified, regenerate_invoice, transmit_invoice,
) )
from pretix.base.services.locking import LockTimeoutException from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import ( from pretix.base.services.mail import prefix_subject, render_mail
SendMailException, prefix_subject, render_mail,
)
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order, OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded, extend_order, mark_order_expired, mark_order_refunded,
@@ -508,9 +507,10 @@ class OrderView(EventPermissionRequiredMixin, DetailView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['can_generate_invoice'] = invoice_qualified(self.order) and ( ctx['invoice_qualified'] = invoice_qualified(self.order)
ctx['can_generate_invoice'] = ctx['invoice_qualified'] and (
self.request.event.settings.invoice_generate in ('admin', 'user', 'paid', 'user_paid', 'True') self.request.event.settings.invoice_generate in ('admin', 'user', 'paid', 'user_paid', 'True')
) and self.order.status in (Order.STATUS_PAID, Order.STATUS_PENDING) and ( ) and (
not self.order.invoices.exists() not self.order.invoices.exists()
or self.order.invoices.filter(is_cancellation=True).count() >= self.order.invoices.filter(is_cancellation=False).count() or self.order.invoices.filter(is_cancellation=True).count() >= self.order.invoices.filter(is_cancellation=False).count()
) )
@@ -1064,10 +1064,6 @@ class OrderPaymentConfirm(OrderView):
messages.error(self.request, str(e)) messages.error(self.request, str(e))
except PaymentException as e: except PaymentException as e:
messages.error(self.request, str(e)) messages.error(self.request, str(e))
except SendMailException:
messages.warning(self.request,
_('The payment has been marked as complete, but we were unable to send a '
'confirmation mail.'))
else: else:
messages.success(self.request, _('The payment has been marked as complete.')) messages.success(self.request, _('The payment has been marked as complete.'))
else: else:
@@ -1538,9 +1534,6 @@ class OrderTransition(OrderView):
'message': str(e) 'message': str(e)
}) })
messages.error(self.request, str(e)) messages.error(self.request, str(e))
except SendMailException:
messages.warning(self.request, _('The order has been marked as paid, but we were unable to send a '
'confirmation mail.'))
else: else:
messages.success(self.request, _('The payment has been created successfully.')) messages.success(self.request, _('The payment has been created successfully.'))
elif self.order.cancel_allowed() and to == 'c': elif self.order.cancel_allowed() and to == 'c':
@@ -1741,14 +1734,15 @@ class OrderInvoiceReissue(OrderView):
messages.error(self.request, _('The invoice has been cleaned of personal data.')) messages.error(self.request, _('The invoice has been cleaned of personal data.'))
else: else:
c = generate_cancellation(inv) c = generate_cancellation(inv)
if order.status not in (Order.STATUS_CANCELED, Order.STATUS_EXPIRED): if invoice_qualified(order):
inv = generate_invoice(order) inv = generate_invoice(order)
messages.success(self.request, _('The invoice has been reissued.'))
else: else:
inv = c inv = c
messages.success(self.request, _('The invoice has been canceled.'))
order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={ order.log_action('pretix.event.order.invoice.reissued', user=self.request.user, data={
'invoice': inv.pk 'invoice': inv.pk
}) })
messages.success(self.request, _('The invoice has been reissued.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())
def get(self, *args, **kwargs): # NOQA def get(self, *args, **kwargs): # NOQA
@@ -1778,15 +1772,11 @@ class OrderResendLink(OrderView):
permission = 'can_change_orders' permission = 'can_change_orders'
def post(self, *args, **kwargs): def post(self, *args, **kwargs):
try: if 'position' in kwargs:
if 'position' in kwargs: p = get_object_or_404(self.order.positions, pk=kwargs['position'])
p = get_object_or_404(self.order.positions, pk=kwargs['position']) p.resend_link(user=self.request.user)
p.resend_link(user=self.request.user) else:
else: self.order.resend_link(user=self.request.user)
self.order.resend_link(user=self.request.user)
except SendMailException:
messages.error(self.request, _('There was an error sending the mail. Please try again later.'))
return redirect(self.get_order_url())
messages.success(self.request, _('The email has been queued to be sent.')) messages.success(self.request, _('The email has been queued to be sent.'))
return redirect(self.get_order_url()) return redirect(self.get_order_url())
@@ -2430,24 +2420,18 @@ class OrderSendMail(EventPermissionRequiredMixin, OrderViewMixin, FormView):
} }
return self.get(self.request, *self.args, **self.kwargs) return self.get(self.request, *self.args, **self.kwargs)
else: else:
try: order.send_mail(
order.send_mail( form.cleaned_data['subject'], email_template,
form.cleaned_data['subject'], email_template, email_context, 'pretix.event.order.email.custom_sent',
email_context, 'pretix.event.order.email.custom_sent', self.request.user, auto_email=False,
self.request.user, auto_email=False, attach_tickets=form.cleaned_data.get('attach_tickets', False),
attach_tickets=form.cleaned_data.get('attach_tickets', False), invoices=form.cleaned_data.get('attach_invoices', []),
invoices=form.cleaned_data.get('attach_invoices', []), attach_other_files=[a for a in [
attach_other_files=[a for a in [ self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] ] if a] if form.cleaned_data.get('attach_new_order', False) else [],
] if a] if form.cleaned_data.get('attach_new_order', False) else [], )
) messages.success(self.request,
messages.success(self.request, _('Your message has been queued and will be sent to {}.'.format(order.email)))
_('Your message has been queued and will be sent to {}.'.format(order.email)))
except SendMailException:
messages.error(
self.request,
_('Failed to send mail to the following user: {}'.format(order.email))
)
return super(OrderSendMail, self).form_valid(form) return super(OrderSendMail, self).form_valid(form)
def get_success_url(self): def get_success_url(self):
@@ -2500,23 +2484,19 @@ class OrderPositionSendMail(OrderSendMail):
} }
return self.get(self.request, *self.args, **self.kwargs) return self.get(self.request, *self.args, **self.kwargs)
else: else:
try: position.send_mail(
position.send_mail( form.cleaned_data['subject'],
form.cleaned_data['subject'], email_template,
email_template, email_context,
email_context, 'pretix.event.order.position.email.custom_sent',
'pretix.event.order.position.email.custom_sent', self.request.user,
self.request.user, attach_tickets=form.cleaned_data.get('attach_tickets', False),
attach_tickets=form.cleaned_data.get('attach_tickets', False), attach_other_files=[a for a in [
attach_other_files=[a for a in [ self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):]
self.request.event.settings.get('mail_attachment_new_order', as_type=str, default='')[len('file://'):] ] if a] if form.cleaned_data.get('attach_new_order', False) else [],
] if a] if form.cleaned_data.get('attach_new_order', False) else [], )
) messages.success(self.request,
messages.success(self.request, _('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
_('Your message has been queued and will be sent to {}.'.format(position.attendee_email)))
except SendMailException:
messages.error(self.request,
_('Failed to send mail to the following user: {}'.format(position.attendee_email)))
return super(OrderSendMail, self).form_valid(form) return super(OrderSendMail, self).form_valid(form)
@@ -2677,8 +2657,8 @@ class ExportMixin:
if id != ex.identifier: if id != ex.identifier:
continue continue
if self.scheduled: if self.scheduled or self.scheduled_copy_from:
initial = dict(self.scheduled.export_form_data) initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields test_form.fields = ex.export_form_fields
@@ -2721,6 +2701,11 @@ class ExportMixin:
elif "scheduled" in self.request.GET: elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled")) return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
@cached_property
def scheduled_copy_from(self):
if "scheduled_copy_from" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
ctx['exporters'] = self.exporters ctx['exporters'] = self.exporters
@@ -2790,7 +2775,16 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic() @transaction.atomic()
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save": if request.POST.get("schedule") == "save":
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid(): if not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
return super().get(request, *args, **kwargs)
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule()) self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
@@ -2829,6 +2823,8 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
def rrule_form(self): def rrule_form(self):
if self.scheduled: if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule) initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
elif self.scheduled_copy_from:
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
else: else:
initial = {} initial = {}
return RRuleForm( return RRuleForm(
@@ -2839,11 +2835,15 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
@cached_property @cached_property
def schedule_form(self): def schedule_form(self):
instance = self.scheduled or ScheduledEventExport( if self.scheduled_copy_from:
event=self.request.event, instance = copy.copy(self.scheduled_copy_from)
owner=self.request.user, instance.pk = None
) else:
if not self.scheduled: instance = self.scheduled or ScheduledEventExport(
event=self.request.event,
owner=self.request.user,
)
if not self.scheduled and not self.scheduled_copy_from:
initial = { initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name), "mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
"mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format( "mail_template": gettext("Hello,\n\nattached to this email, you can find a new scheduled report for {name}.").format(
@@ -2868,18 +2868,11 @@ class ExportView(EventPermissionRequiredMixin, ExportMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled:
if "schedule" in self.request.POST and not self.has_permission(): if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
messages.error( ctx['schedule_form'] = self.schedule_form
self.request, ctx['rrule_form'] = self.rrule_form
_( ctx['scheduled_copy_from'] = self.scheduled_copy_from
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
else:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
elif not self.exporter: elif not self.exporter:
for s in ctx['scheduled']: for s in ctx['scheduled']:
try: try:
+51 -40
View File
@@ -32,6 +32,7 @@
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the # 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. # License for the specific language governing permissions and limitations under the License.
import copy
import json import json
import logging import logging
import re import re
@@ -102,7 +103,7 @@ from pretix.base.plugins import (
PLUGIN_LEVEL_ORGANIZER, PLUGIN_LEVEL_ORGANIZER,
) )
from pretix.base.services.export import multiexport, scheduled_organizer_export from pretix.base.services.export import multiexport, scheduled_organizer_export
from pretix.base.services.mail import SendMailException, mail, prefix_subject from pretix.base.services.mail import mail, prefix_subject
from pretix.base.signals import register_multievent_data_exporters from pretix.base.signals import register_multievent_data_exporters
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
from pretix.base.views.tasks import AsyncAction from pretix.base.views.tasks import AsyncAction
@@ -1036,24 +1037,21 @@ class TeamMemberView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin,
return ctx return ctx
def _send_invite(self, instance): def _send_invite(self, instance):
try: mail(
mail( instance.email,
instance.email, _('pretix account invitation'),
_('pretix account invitation'), 'pretixcontrol/email/invitation.txt',
'pretixcontrol/email/invitation.txt', {
{ 'user': self,
'user': self, 'organizer': self.request.organizer.name,
'organizer': self.request.organizer.name, 'team': instance.team.name,
'team': instance.team.name, 'url': build_global_uri('control:auth.invite', kwargs={
'url': build_global_uri('control:auth.invite', kwargs={ 'token': instance.token
'token': instance.token })
}) },
}, event=None,
event=None, locale=self.request.LANGUAGE_CODE
locale=self.request.LANGUAGE_CODE )
)
except SendMailException:
pass # Already logged
@transaction.atomic @transaction.atomic
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
@@ -1943,8 +1941,8 @@ class ExportMixin:
for ex in self.exporters: for ex in self.exporters:
if id != ex.identifier: if id != ex.identifier:
continue continue
if self.scheduled: if self.scheduled or self.scheduled_copy_from:
initial = dict(self.scheduled.export_form_data) initial = dict((self.scheduled or self.scheduled_copy_from).export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier) test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields test_form.fields = ex.export_form_fields
@@ -2047,6 +2045,11 @@ class ExportMixin:
elif "scheduled" in self.request.GET: elif "scheduled" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled")) return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled"))
@cached_property
def scheduled_copy_from(self):
if "scheduled_copy_from" in self.request.GET:
return get_object_or_404(self.get_scheduled_queryset(), pk=self.request.GET.get("scheduled_copy_from"))
class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView): class ExportDoView(OrganizerPermissionRequiredMixin, ExportMixin, AsyncAction, TemplateView):
known_errortypes = ['ExportError', 'ExportEmptyError'] known_errortypes = ['ExportError', 'ExportEmptyError']
@@ -2113,7 +2116,16 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
@transaction.atomic() @transaction.atomic()
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
if request.POST.get("schedule") == "save": if request.POST.get("schedule") == "save":
if self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid(): if not self.has_permission():
messages.error(
self.request,
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
return super().get(request, *args, **kwargs)
elif self.exporter.form.is_valid() and self.rrule_form.is_valid() and self.schedule_form.is_valid():
self.schedule_form.instance.export_identifier = self.exporter.identifier self.schedule_form.instance.export_identifier = self.exporter.identifier
self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data self.schedule_form.instance.export_form_data = self.exporter.form.cleaned_data
self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule()) self.schedule_form.instance.schedule_rrule = str(self.rrule_form.to_rrule())
@@ -2151,6 +2163,8 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
def rrule_form(self): def rrule_form(self):
if self.scheduled: if self.scheduled:
initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule) initial = RRuleForm.initial_from_rrule(self.scheduled.schedule_rrule)
elif self.scheduled_copy_from:
initial = RRuleForm.initial_from_rrule(self.scheduled_copy_from.schedule_rrule)
else: else:
initial = {} initial = {}
return RRuleForm( return RRuleForm(
@@ -2162,11 +2176,15 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
@cached_property @cached_property
def schedule_form(self): def schedule_form(self):
instance = self.scheduled or ScheduledOrganizerExport( if self.scheduled_copy_from:
organizer=self.request.organizer, instance = copy.copy(self.scheduled_copy_from)
owner=self.request.user, instance.pk = None
timezone=str(get_current_timezone()), else:
) instance = self.scheduled or ScheduledOrganizerExport(
organizer=self.request.organizer,
owner=self.request.user,
timezone=str(get_current_timezone()),
)
if not self.scheduled: if not self.scheduled:
initial = { initial = {
"mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name), "mail_subject": gettext("Export: {title}").format(title=self.exporter.verbose_name),
@@ -2199,18 +2217,10 @@ class ExportView(OrganizerPermissionRequiredMixin, ExportMixin, ListView):
def get_context_data(self, **kwargs): def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs) ctx = super().get_context_data(**kwargs)
if "schedule" in self.request.POST or self.scheduled: if "schedule" in self.request.POST or self.scheduled or self.scheduled_copy_from:
if "schedule" in self.request.POST and not self.has_permission(): ctx['schedule_form'] = self.schedule_form
messages.error( ctx['rrule_form'] = self.rrule_form
self.request, ctx['scheduled_copy_from'] = self.scheduled_copy_from
_(
"Your user account does not have sufficient permission to run this report, therefore "
"you cannot schedule it."
)
)
else:
ctx['schedule_form'] = self.schedule_form
ctx['rrule_form'] = self.rrule_form
elif not self.exporter: elif not self.exporter:
for s in ctx['scheduled']: for s in ctx['scheduled']:
try: try:
@@ -2583,7 +2593,7 @@ class LogView(OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
def get_queryset(self): def get_queryset(self):
# technically, we'd also need to sort by pk since this is a paginated list, but in this case we just can't # technically, we'd also need to sort by pk since this is a paginated list, but in this case we just can't
# bear the performance cost # bear the performance cost
qs = self.request.organizer.all_logentries().select_related( qs = self.request.organizer.logentry_set.filter(event=None).select_related(
'user', 'content_type', 'api_token', 'oauth_application', 'device' 'user', 'content_type', 'api_token', 'oauth_application', 'device'
).order_by('-datetime') ).order_by('-datetime')
qs = qs.exclude(action_type__in=OVERVIEW_BANLIST) qs = qs.exclude(action_type__in=OVERVIEW_BANLIST)
@@ -3014,6 +3024,7 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
locale=self.customer.locale, locale=self.customer.locale,
customer=self.customer, customer=self.customer,
organizer=self.request.organizer, organizer=self.request.organizer,
sensitive=True,
) )
messages.success( messages.success(
self.request, self.request,
+5 -6
View File
@@ -41,7 +41,6 @@ from hijack import signals
from pretix.base.auth import get_auth_backends from pretix.base.auth import get_auth_backends
from pretix.base.models import User from pretix.base.models import User
from pretix.base.services.mail import SendMailException
from pretix.control.forms.filter import UserFilterForm from pretix.control.forms.filter import UserFilterForm
from pretix.control.forms.users import UserEditForm from pretix.control.forms.users import UserEditForm
from pretix.control.permissions import AdministratorPermissionRequiredMixin from pretix.control.permissions import AdministratorPermissionRequiredMixin
@@ -139,11 +138,7 @@ class UserResetView(AdministratorPermissionRequiredMixin, RecentAuthenticationRe
def post(self, request, *args, **kwargs): def post(self, request, *args, **kwargs):
self.object = get_object_or_404(User, pk=self.kwargs.get("id")) self.object = get_object_or_404(User, pk=self.kwargs.get("id"))
try: self.object.send_password_reset()
self.object.send_password_reset()
except SendMailException:
messages.error(request, _('There was an error sending the mail. Please try again later.'))
return redirect(self.get_success_url())
self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent', self.object.log_action('pretix.control.auth.user.forgot_password.mail_sent',
user=request.user) user=request.user)
@@ -165,6 +160,10 @@ class UserEmergencyTokenView(AdministratorPermissionRequiredMixin, RecentAuthent
d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency') d, __ = StaticDevice.objects.get_or_create(user=self.object, name='emergency')
token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890')) token = d.token_set.create(token=get_random_string(length=12, allowed_chars='1234567890'))
self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user) self.object.log_action('pretix.user.settings.2fa.emergency', user=self.request.user)
self.object.send_security_notice([
_('A two-factor emergency code has been generated by a system administrator. This will usually happen '
'if you lost access to your two-factor credentials and requested a reset of the credentials.')
])
messages.success(request, _( messages.success(request, _(
'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit ' 'The emergency token for this user is "{token}". It can only be used once. Please make sure to transmit '
+16
View File
@@ -136,3 +136,19 @@ custom_translations = [
gettext_noop("North Macedonia"), gettext_noop("North Macedonia"),
gettext_noop("Macao"), gettext_noop("Macao"),
] ]
def pycountry_add(db, **kw):
# Workaround for https://github.com/pycountry/pycountry/issues/281
db._load()
obj = db.factory(**kw)
db.objects.append(obj)
for key, value in kw.items():
if key in db.no_index:
continue
value = value.lower()
index = db.indices.setdefault(key, {})
if key in ["country_code"]:
index.setdefault(value, set()).add(obj)
else:
index[value] = obj
+14 -1
View File
@@ -25,7 +25,7 @@ from django.conf import settings
from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured from django.core.exceptions import FieldDoesNotExist, ImproperlyConfigured
from django.db import connection, transaction from django.db import connection, transaction
from django.db.models import ( from django.db.models import (
Aggregate, Expression, F, Field, Lookup, OrderBy, Value, Aggregate, Expression, F, Field, JSONField, Lookup, OrderBy, Value,
) )
from django.utils.functional import lazy from django.utils.functional import lazy
@@ -154,6 +154,19 @@ class NotEqual(Lookup):
return '%s <> %s' % (lhs, rhs), params return '%s <> %s' % (lhs, rhs), params
@JSONField.register_lookup
class ContainsString(Lookup):
lookup_name = 'containsstring'
def as_sql(self, compiler, connection):
if connection.vendor != "postgresql":
raise NotImplementedError("Lookup in JSON Array not supported on this database")
lhs, lhs_params = self.process_lhs(compiler, connection)
rhs, rhs_params = self.process_rhs(compiler, connection)
params = lhs_params + rhs_params
return '%s ? %s' % (lhs, rhs), params
class PostgresWindowFrame(Expression): class PostgresWindowFrame(Expression):
template = "%(frame_type)s BETWEEN %(start)s AND %(end)s" template = "%(frame_type)s BETWEEN %(start)s AND %(end)s"
+16 -19
View File
@@ -32,7 +32,7 @@ from django_countries.fields import Country
from geoip2.errors import AddressNotFoundError from geoip2.errors import AddressNotFoundError
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import mail
from pretix.helpers.http import get_client_ip from pretix.helpers.http import get_client_ip
from pretix.helpers.urls import build_absolute_uri from pretix.helpers.urls import build_absolute_uri
@@ -159,21 +159,18 @@ def handle_login_source(user, request):
}) })
if user.known_login_sources.count() > 1: if user.known_login_sources.count() > 1:
# Do not send on first login or first login after introduction of this feature: # Do not send on first login or first login after introduction of this feature:
try: with language(user.locale):
with language(user.locale): mail(
mail( user.email,
user.email, _('Login from new source detected'),
_('Login from new source detected'), 'pretixcontrol/email/login_notice.txt',
'pretixcontrol/email/login_notice.txt', {
{ 'source': src,
'source': src, 'country': Country(str(country)).name if country else _('Unknown country'),
'country': Country(str(country)).name if country else _('Unknown country'), 'instance': settings.PRETIX_INSTANCE_NAME,
'instance': settings.PRETIX_INSTANCE_NAME, 'url': build_absolute_uri('control:user.settings')
'url': build_absolute_uri('control:user.settings') },
}, event=None,
event=None, user=user,
user=user, locale=user.locale
locale=user.locale )
)
except SendMailException:
pass # Not much we can do
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2021-09-15 11:22+0000\n" "PO-Revision-Date: 2021-09-15 11:22+0000\n"
"Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n" "Last-Translator: Mohamed Tawfiq <mtawfiq@wafyapp.com>\n"
"Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/" "Language-Team: Arabic <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -153,16 +153,26 @@ msgid "Payment method unavailable"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "طلبات مختارة" msgstr "طلبات مختارة"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "الطلبات المدفوعة" msgstr "الطلبات المدفوعة"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "إجمالي الإيرادات" msgstr "إجمالي الإيرادات"
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2025-10-31 17:00+0000\n" "PO-Revision-Date: 2025-10-31 17:00+0000\n"
"Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n" "Last-Translator: Núria Masclans <nuriamasclansserrat@gmail.com>\n"
"Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Catalan <https://translate.pretix.eu/projects/pretix/pretix-"
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
msgstr "El mètode de pagament no està disponible" msgstr "El mètode de pagament no està disponible"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "Comanda realitzada" msgstr "Comanda realitzada"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "Comandes pagades" msgstr "Comandes pagades"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "Facturació total" msgstr "Facturació total"
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2026-01-08 04:00+0000\n" "PO-Revision-Date: 2026-01-08 04:00+0000\n"
"Last-Translator: Jiří Pastrňák <jiri@pastrnak.email>\n" "Last-Translator: Jiří Pastrňák <jiri@pastrnak.email>\n"
"Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/" "Language-Team: Czech <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
msgstr "Způsob platby není k dispozici" msgstr "Způsob platby není k dispozici"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "Zadané objednávky" msgstr "Zadané objednávky"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "Zaplacené objednávky" msgstr "Zaplacené objednávky"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "Celkové příjmy" msgstr "Celkové příjmy"
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -6,7 +6,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2024-07-10 15:00+0000\n" "PO-Revision-Date: 2024-07-10 15:00+0000\n"
"Last-Translator: Nikolai <nikolai@lengefeldt.de>\n" "Last-Translator: Nikolai <nikolai@lengefeldt.de>\n"
"Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/" "Language-Team: Danish <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -154,16 +154,26 @@ msgid "Payment method unavailable"
msgstr "Betalingsmetode er ikke tilgængelig" msgstr "Betalingsmetode er ikke tilgængelig"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "Afgivne bestillinger" msgstr "Afgivne bestillinger"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "Betalte bestillinger" msgstr "Betalte bestillinger"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "Omsætning i alt" msgstr "Omsætning i alt"
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2025-10-29 09:24+0000\n" "PO-Revision-Date: 2025-10-29 09:24+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n" "Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/" "Language-Team: German <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
msgstr "Zahlungsmethode nicht verfügbar" msgstr "Zahlungsmethode nicht verfügbar"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "Getätigte Bestellungen" msgstr "Getätigte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "Bezahlte Bestellungen" msgstr "Bezahlte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "Gesamtumsatz" msgstr "Gesamtumsatz"
File diff suppressed because it is too large Load Diff
@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2025-10-29 09:24+0000\n" "PO-Revision-Date: 2025-10-29 09:24+0000\n"
"Last-Translator: Raphael Michel <michel@rami.io>\n" "Last-Translator: Raphael Michel <michel@rami.io>\n"
"Language-Team: German (informal) <https://translate.pretix.eu/projects/" "Language-Team: German (informal) <https://translate.pretix.eu/projects/"
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
msgstr "Zahlungsmethode nicht verfügbar" msgstr "Zahlungsmethode nicht verfügbar"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "Getätigte Bestellungen" msgstr "Getätigte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "Bezahlte Bestellungen" msgstr "Bezahlte Bestellungen"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "Gesamtumsatz" msgstr "Gesamtumsatz"
+1782 -1708
View File
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 13:20+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -7,7 +7,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2024-12-22 00:00+0000\n" "PO-Revision-Date: 2024-12-22 00:00+0000\n"
"Last-Translator: Dimitris Tsimpidis <tsimpidisd@gmail.com>\n" "Last-Translator: Dimitris Tsimpidis <tsimpidisd@gmail.com>\n"
"Language-Team: Greek <https://translate.pretix.eu/projects/pretix/pretix-js/" "Language-Team: Greek <https://translate.pretix.eu/projects/pretix/pretix-js/"
@@ -153,16 +153,26 @@ msgid "Payment method unavailable"
msgstr "Μη διαθέσιμος τρόπος πληρωμής" msgstr "Μη διαθέσιμος τρόπος πληρωμής"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "Παραγγελίες" msgstr "Παραγγελίες"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "Πληρωμένες παραγγελίες" msgstr "Πληρωμένες παραγγελίες"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "Συνολικά κέρδη" msgstr "Συνολικά κέρδη"
File diff suppressed because it is too large Load Diff
+13 -3
View File
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n" "PO-Revision-Date: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n" "Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\n" "Language-Team: LANGUAGE <LL@li.org>\n"
@@ -149,16 +149,26 @@ msgid "Payment method unavailable"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "" msgstr ""
File diff suppressed because it is too large Load Diff
+19 -12
View File
@@ -7,8 +7,8 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: PACKAGE VERSION\n" "Project-Id-Version: PACKAGE VERSION\n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2025-10-22 16:00+0000\n" "PO-Revision-Date: 2026-01-27 14:51+0000\n"
"Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n" "Last-Translator: CVZ-es <damien.bremont@casadevelazquez.org>\n"
"Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-" "Language-Team: Spanish <https://translate.pretix.eu/projects/pretix/pretix-"
"js/es/>\n" "js/es/>\n"
@@ -17,7 +17,7 @@ msgstr ""
"Content-Type: text/plain; charset=UTF-8\n" "Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n" "Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=n != 1;\n" "Plural-Forms: nplurals=2; plural=n != 1;\n"
"X-Generator: Weblate 5.13.3\n" "X-Generator: Weblate 5.15.2\n"
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:56
#: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62 #: pretix/plugins/banktransfer/static/pretixplugins/banktransfer/ui.js:62
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
msgstr "Forma de pago no disponible" msgstr "Forma de pago no disponible"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "Órdenes enviadas" msgstr "Órdenes enviadas"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "Órdenes pagadas" msgstr "Órdenes pagadas"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr "Asistentes (por orden alfabético)"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr "Asistentes (de pago)"
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "Ingresos totales" msgstr "Ingresos totales"
@@ -738,11 +748,8 @@ msgstr "Su carrito está a punto de caducar."
#: pretix/static/pretixpresale/js/ui/cart.js:62 #: pretix/static/pretixpresale/js/ui/cart.js:62
msgid "The items in your cart are reserved for you for one minute." msgid "The items in your cart are reserved for you for one minute."
msgid_plural "The items in your cart are reserved for you for {num} minutes." msgid_plural "The items in your cart are reserved for you for {num} minutes."
msgstr[0] "" msgstr[0] "Los artículos de la cesta están reservados durante un minuto."
"Los elementos en su carrito de compras se han reservado durante un minuto." msgstr[1] "Los artículos de la cesta están reservados durante {num} minutos."
msgstr[1] ""
"Los elementos en su carrito de compras se han reservado durante {num} "
"minutos."
#: pretix/static/pretixpresale/js/ui/cart.js:83 #: pretix/static/pretixpresale/js/ui/cart.js:83
msgid "Your cart has expired." msgid "Your cart has expired."
@@ -766,11 +773,11 @@ msgstr "Renovar reserva"
#: pretix/static/pretixpresale/js/ui/main.js:194 #: pretix/static/pretixpresale/js/ui/main.js:194
msgid "The organizer keeps %(currency)s %(amount)s" msgid "The organizer keeps %(currency)s %(amount)s"
msgstr "El organizador se queda %(currency)s %(price)s" msgstr "El organizador retiene %(currency)s %(amount)s"
#: pretix/static/pretixpresale/js/ui/main.js:202 #: pretix/static/pretixpresale/js/ui/main.js:202
msgid "You get %(currency)s %(amount)s back" msgid "You get %(currency)s %(amount)s back"
msgstr "Obtienes %(currency)s %(price)s de vuelta" msgstr "Se le devolverá %(moneda)s %(cantidad)s"
#: pretix/static/pretixpresale/js/ui/main.js:218 #: pretix/static/pretixpresale/js/ui/main.js:218
msgid "Please enter the amount the organizer can keep." msgid "Please enter the amount the organizer can keep."
File diff suppressed because it is too large Load Diff
@@ -8,7 +8,7 @@ msgid ""
msgstr "" msgstr ""
"Project-Id-Version: \n" "Project-Id-Version: \n"
"Report-Msgid-Bugs-To: \n" "Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2026-01-05 12:13+0000\n" "POT-Creation-Date: 2026-01-26 09:10+0000\n"
"PO-Revision-Date: 2025-08-04 14:16+0200\n" "PO-Revision-Date: 2025-08-04 14:16+0200\n"
"Last-Translator: \n" "Last-Translator: \n"
"Language-Team: \n" "Language-Team: \n"
@@ -150,16 +150,26 @@ msgid "Payment method unavailable"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Placed orders" msgid "Placed orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:15
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:63
msgid "Paid orders" msgid "Paid orders"
msgstr "" msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27 #: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (ordered)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:27
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:39
msgid "Attendees (paid)"
msgstr ""
#: pretix/plugins/statistics/static/pretixplugins/statistics/statistics.js:51
msgid "Total revenue" msgid "Total revenue"
msgstr "" msgstr ""

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