Compare commits

...

194 Commits

Author SHA1 Message Date
Raphael Michel dd3821983f Remove back link from 404 error page (#23222967)
I've kept it for 400/403/500/csrffail for now, because they also have a
"try again" link. Yes, both things have browser buttons, but they make
it a *little* clearer to technical users what one could to next, and
especially on csrffail, "step back" is always possible and possibly actually
helpful.
2026-02-17 09:31:24 +01:00
Raphael Michel 119cc50897 Fix inconsistent singular/plural use in text (Z#23223585) 2026-02-17 09:31:08 +01:00
Raphael Michel 61f9cf13b4 Order change: Fix list of unchangeable add-ons not filtered to category (Z#23223330) (#5876) 2026-02-16 15:13:24 +01:00
Raphael Michel f24429a7c5 Fix tests on Python <3.11 2026-02-16 13:40:00 +01:00
Raphael Michel 29ed07ccce Merge branch 'pajowu/security-plaintext-placeholder' into 'master'
SECURITY: Prevent placeholder injection in plaintext emails

See merge request pretix/pretix!21
2026-02-16 10:59:44 +01:00
Nate Horst dd0cd7ab0b Translations: Update Thai
Currently translated at 36.0% (2237 of 6207 strings)

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

powered by weblate
2026-02-16 10:44:21 +01:00
Nate Horst d7df906995 Translations: Update Thai
Currently translated at 36.0% (2237 of 6207 strings)

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

powered by weblate
2026-02-16 10:44:21 +01:00
Ruud Hendrickx 839f4b4657 Translations: Update Dutch (Belgium)
Currently translated at 0.1% (12 of 6207 strings)

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

powered by weblate
2026-02-16 10:44:21 +01:00
Ruud Hendrickx 74f7e1f61c Translations: Add Dutch (Belgium) 2026-02-16 10:44:21 +01:00
Yasunobu YesNo Kawaguchi 47919afab0 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-02-16 10:44:21 +01:00
Yasunobu YesNo Kawaguchi 819daa99f7 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-02-16 10:44:21 +01:00
Ruud Hendrickx 8512e79d68 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-02-16 10:44:21 +01:00
Ruud Hendrickx 52672ae25b 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-02-16 10:44:21 +01:00
Raphael Michel ad752dc617 Fix placeholder injection with django templates 2026-02-13 13:36:12 +01:00
Raphael Michel 43c6c33bd8 SafeFormatter: Ignore conversion spec 2026-02-13 12:35:49 +01:00
Raphael Michel 88c9f8c047 Remove duplicate rendering of plain content without variables 2026-02-13 12:30:01 +01:00
Raphael Michel 2d2663f15f Mark strings as formatted to prevent double-formatting 2026-02-13 12:28:32 +01:00
Kara Engelhardt ae6014708b SECURITY: Prevent placeholder injcetion in plaintext emails 2026-02-13 12:28:32 +01:00
Richard Schreiber d1686df07c Move request.GET.items to ctx (#5889) 2026-02-12 12:05:08 +01:00
Richard Schreiber 4d60d7bfbc Fix widget quantity prefill (#5886) 2026-02-12 12:04:11 +01:00
Phin Wolkwitz c0b93fedc5 Hide company name field in order info for individual customers (Z#23212149, Z#23216249) (#5887) 2026-02-11 16:15:23 +01:00
Richard Schreiber 2eaa6c3069 Fix address-helper wrong locale (Z#23223920) (#5884)
* Fix address-helper wrong locale (Z#23223920)

* fix translation for transmission-types names

* use language_code instead
2026-02-11 13:22:15 +01:00
Phin Wolkwitz db982c9ef4 Presale: Hide adress info from invisible fields in confirmation step (Z#23212149) (#5649)
Not all transmission fields are visible to users at all times, depending on whether they are necessary for users to know/change but they are submitted for the backend. This change hides those fields that were hidden before in the confirmation step as well to avoid confusion.
2026-02-11 13:14:05 +01:00
Raphael Michel f9f6ee94ae Outgoing mails: Fix wrong filter statement (PRETIXEU-CZZ) 2026-02-11 13:11:21 +01:00
Lukas Bockstaller 99c257d392 adds webhooks for giftcards (Z#23205473) (#5834)
* adds giftcard webhook events

* maps issuer_id of giftcard to organizer_id for logging

* adds new giftcard logtypes for transactions that aren't manual

* log_action calls cleanup

* drop acceptance webhook

* add acceptor_id to the giftcard transaction webhook event

* add missing log_action statements

* add new webhooks to docs

* fix tests

* fix linting
2026-02-11 12:51:09 +01:00
Richard Schreiber e2cb83ce28 Fix marking invoices transmitted for emails with uppercase letters (#5885) 2026-02-11 12:00:54 +01:00
Raffaele Doretto d7b7d3cc5f Translations: Update Italian
Currently translated at 67.5% (173 of 256 strings)

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

powered by weblate
2026-02-10 18:08:28 +01:00
Michele Pagnozzi 721ac8a500 Translations: Update Italian
Currently translated at 39.5% (2454 of 6207 strings)

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

powered by weblate
2026-02-10 18:08:28 +01:00
roi belotsercovsky 5796cfe03f Translations: Update Hebrew
Currently translated at 95.4% (5927 of 6207 strings)

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

powered by weblate
2026-02-10 18:08:28 +01:00
roi belotsercovsky 63f1c4f793 Translations: Update Hebrew
Currently translated at 100.0% (256 of 256 strings)

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

powered by weblate
2026-02-10 18:08:28 +01:00
Raphael Michel 47f409171d Customer accounts: Add security notices (#5705)
* Customer accounts: Add security notices

* Apply suggestions from code review
2026-02-10 17:55:53 +01:00
dependabot[bot] 27fcdff17f Update sphinxcontrib-httpdomain requirement from ~=1.8.1 to ~=2.0.0 (#5877)
Updates the requirements on [sphinxcontrib-httpdomain](https://github.com/sphinx-contrib/httpdomain) to permit the latest version.
- [Release notes](https://github.com/sphinx-contrib/httpdomain/releases)
- [Changelog](https://github.com/sphinx-contrib/httpdomain/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/sphinx-contrib/httpdomain/compare/1.8.1...2.0.0)

---
updated-dependencies:
- dependency-name: sphinxcontrib-httpdomain
  dependency-version: 2.0.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-02-10 17:54:21 +01:00
dependabot[bot] a38a96f186 Update pyjwt requirement from ==2.10.* to ==2.11.* (#5872)
Updates the requirements on [pyjwt](https://github.com/jpadilla/pyjwt) to permit the latest version.
- [Release notes](https://github.com/jpadilla/pyjwt/releases)
- [Changelog](https://github.com/jpadilla/pyjwt/blob/master/CHANGELOG.rst)
- [Commits](https://github.com/jpadilla/pyjwt/compare/2.10.0...2.11.0)

---
updated-dependencies:
- dependency-name: pyjwt
  dependency-version: 2.11.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-02-10 17:53:59 +01:00
dependabot[bot] 700ea77e39 Update css-inline requirement from ==0.19.* to ==0.20.* (#5883)
Updates the requirements on [css-inline](https://github.com/Stranger6667/css-inline) to permit the latest version.
- [Release notes](https://github.com/Stranger6667/css-inline/releases)
- [Changelog](https://github.com/Stranger6667/css-inline/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Stranger6667/css-inline/compare/c-v0.19.0...c-v0.20.0)

---
updated-dependencies:
- dependency-name: css-inline
  dependency-version: 0.20.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-02-10 17:50:27 +01:00
dependabot[bot] 06104ff483 Bump markdown from 3.10.1 to 3.10.2 (#5882)
Bumps [markdown](https://github.com/Python-Markdown/markdown) from 3.10.1 to 3.10.2.
- [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.1...3.10.2)

---
updated-dependencies:
- dependency-name: markdown
  dependency-version: 3.10.2
  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-02-10 17:50:12 +01:00
luelista fb5697a27b Fix is_available on non-event-level plugins (#5878) 2026-02-10 17:49:17 +01:00
roi belotsercovsky 9a9ad6d6d1 Translations: Update Hebrew
Currently translated at 94.8% (5886 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ryo Tagami a05845790e 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-02-09 12:46:47 +01:00
Ryo Tagami a0830dd033 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-02-09 12:46:47 +01:00
Ruud Hendrickx dba2529f6b Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ruud Hendrickx 9c0ea8f179 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-02-09 12:46:47 +01:00
Ruud Hendrickx 1f0501a647 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-02-09 12:46:47 +01:00
Nate Horst d2e6446238 Translations: Update Thai
Currently translated at 35.5% (2207 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ruud Hendrickx d519fcfe0d 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-02-09 12:46:47 +01:00
Nate Horst c7226303be Translations: Update Thai
Currently translated at 33.7% (2097 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ruud Hendrickx 9406e941bc 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-02-09 12:46:47 +01:00
Nate Horst 919e598f8a Translations: Update Thai
Currently translated at 27.0% (1676 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ryo Tagami 672692d578 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-02-09 12:46:47 +01:00
Ryo Tagami 9429dc7e91 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-02-09 12:46:47 +01:00
Ruud Hendrickx 5c8dbd99dd 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-02-09 12:46:47 +01:00
Ruud Hendrickx cfbb8310f0 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-02-09 12:46:47 +01:00
Ruud Hendrickx d37d9a861c 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-02-09 12:46:47 +01:00
Mie Frydensbjerg 43a9cf29b2 Translations: Update Danish
Currently translated at 45.1% (2804 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ruud Hendrickx 047ad438a7 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-02-09 12:46:47 +01:00
Ruud Hendrickx ec8d921fcf 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-02-09 12:46:47 +01:00
Ruud Hendrickx 39e6ef4365 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-02-09 12:46:47 +01:00
Ruud Hendrickx 4d8b032591 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-02-09 12:46:47 +01:00
Ryo Tagami e8193e408b 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-02-09 12:46:47 +01:00
Ryo Tagami 6723d8c07c 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-02-09 12:46:47 +01:00
z3rrry c30134f36c Translations: Update Korean
Currently translated at 99.2% (254 of 256 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
z3rrry 0617fc04ec Translations: Update Korean
Currently translated at 50.5% (3139 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ryo Tagami e90b54280a 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-02-09 12:46:47 +01:00
Ryo Tagami 39ea2889ba 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-02-09 12:46:47 +01:00
Ryo Tagami 76230bd37b 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-02-09 12:46:47 +01:00
Ryo Tagami 86b8c5e90f 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-02-09 12:46:47 +01:00
Ryo Tagami 7a2027c61b 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-02-09 12:46:47 +01:00
Ryo Tagami 55de5ef45b 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-02-09 12:46:47 +01:00
Nate Horst 39e6954828 Translations: Update Thai
Currently translated at 24.2% (1503 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ryo Tagami 7849d98672 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-02-09 12:46:47 +01:00
Ryo Tagami c325164059 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-02-09 12:46:47 +01:00
Ruud Hendrickx 8fc19c62dd 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-02-09 12:46:47 +01:00
Ryo Tagami 20feaebbbd 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-02-09 12:46:47 +01:00
Ryo Tagami 4587a9e630 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-02-09 12:46:47 +01:00
Ruud Hendrickx 9fe0e6eb67 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-02-09 12:46:47 +01:00
Nate Horst bab7e54f35 Translations: Update Thai
Currently translated at 22.8% (1419 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Ruud Hendrickx 352efa40e7 Translations: Update Dutch (informal) (nl_Informal)
Currently translated at 100.0% (6207 of 6207 strings)

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

powered by weblate
2026-02-09 12:46:47 +01:00
Richard Schreiber 50da7d4261 Fix help-text on date-questions not being translatable (#5875) 2026-02-06 08:40:55 +01:00
dependabot[bot] 53cc59d41d Update sentry-sdk requirement from ==2.51.* to ==2.52.* (#5874)
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.51.0a1...2.52.0)

---
updated-dependencies:
- dependency-name: sentry-sdk
  dependency-version: 2.52.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-02-05 15:34:24 +01:00
Raphael Michel 9879e99c59 Outgoing mail: Decode unicode in From headers (#5864) 2026-02-03 18:12:12 +01:00
Raphael Michel dc49d5bcf7 Add "scheduling" to banned organizer slugs 2026-02-03 12:51:48 +01:00
Richard Schreiber d4460045b4 Fix mail headers being None (#5873)
* Fix mail headers being None

* update tests
2026-02-03 11:26:26 +01:00
dependabot[bot] 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 Michel 6a594a6166 Metrics: Fix length and age of of queues (broken after #5513) (#5865) 2026-02-02 13:37:16 +01:00
Raphael Michel 0e7bb43a5a Manual payment: Fix using hidden method for existing order (#5850) 2026-02-02 12:32:53 +01:00
Richard Schreiber 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
Raphael Michel 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 Michel c40e34af57 Model-based mail queuing 2026-01-30 10:43:02 +01:00
Richard Schreiber 1492ec51bf Limit organizer ical to 1000 entries 2026-01-30 08:59:34 +01:00
robbi5 7ca2a0c910 Remove duplicate device/revoke from api documentation 2026-01-29 20:44:47 +01:00
Ruud Hendrickx 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 Hendrickx 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ňák 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 Hendrickx 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 Horst 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 Horst 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 Hendrickx 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 Hendrickx 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
Raphael Michel 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 Schreiber 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 Schreiber 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] 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 Tagami 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 Tagami 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 Hendrickx 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 Hendrickx 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ňák 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 Hendrickx 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 Horst 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 Horst 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 Hendrickx 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 Hendrickx 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 Hendrickx 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 Tagami 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 Kawaguchi 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 Tagami 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 Hendrickx 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 Hendrickx 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 Tagami 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-es 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-es 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-es 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 Hendrickx 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 Hendrickx 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-es 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 Hendrickx 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 Umemoto 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 Umemoto 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 Rocha 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 Rocha 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 Hendrickx 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 Schreiber 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 Hendrickx 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 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 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 Hendrickx 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 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 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 Hendrickx 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 Michel 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 Michel 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 Michel 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 Michel 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 Michel 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
Raphael Michel 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 Michel 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 Wolkwitz 0d6e1e2271 Prefetch program times, add test for query count (#5822) 2026-01-26 08:38:44 +01:00
Raphael Michel 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 Cross a0dae48cec Prevent double-clicks on SSO login providers (#5842) 2026-01-26 08:31:30 +01:00
Ruud Hendrickx 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 Hendrickx 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 Hendrickx 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 Hendrickx 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 Hendrickx 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ás 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ás 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 Hendrickx 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 Hendrickx 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 Bockstaller 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 Michel fd72e18a7f Overview export: Allow to skip empty lines (Z#23219200) (#5825) 2026-01-23 11:18:07 +01:00
Raphael Michel baac963fa8 API: Fix crash in check-in API (PRETIXEU-CT1) (#5806) 2026-01-23 11:17:21 +01:00
Ruud Hendrickx 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 Hendrickx 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 Michel 4f989cbe8a Order export: Add voucher_budget_use (Z#23218461) 2026-01-22 21:26:42 +01:00
Raphael 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 Hendrickx 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ňák 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 Hendrickx 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 Hendrickx 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 Hendrickx 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 Hendrickx 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 Hendrickx 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 Hendrickx 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ňák 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 Thelander 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] 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] 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] 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 Bockstaller 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 Bockstaller 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 Schreiber 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 Schreiber ceb2e13d27 Remove autofocus from only button in cart-extend confirm-dialog (#5821) 2026-01-20 09:53:16 +01:00
242 changed files with 146466 additions and 106470 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
--------------- ---------------
+3
View File
@@ -60,6 +60,9 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.added`` * ``pretix.event.added``
* ``pretix.event.changed`` * ``pretix.event.changed``
* ``pretix.event.deleted`` * ``pretix.event.deleted``
* ``pretix.giftcards.created``
* ``pretix.giftcards.modified``
* ``pretix.giftcards.transaction.*``
* ``pretix.voucher.added`` * ``pretix.voucher.added``
* ``pretix.voucher.changed`` * ``pretix.voucher.changed``
* ``pretix.voucher.deleted`` * ``pretix.voucher.deleted``
+1 -1
View File
@@ -1,6 +1,6 @@
sphinx==9.1.* sphinx==9.1.*
sphinx-rtd-theme~=3.1.0 sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=1.8.1 sphinxcontrib-httpdomain~=2.0.0
sphinxcontrib-images~=1.0.1 sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1 sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2 sphinxcontrib-spelling~=8.0.2
+1 -1
View File
@@ -1,7 +1,7 @@
-e ../ -e ../
sphinx==9.1.* sphinx==9.1.*
sphinx-rtd-theme~=3.1.0 sphinx-rtd-theme~=3.1.0
sphinxcontrib-httpdomain~=1.8.1 sphinxcontrib-httpdomain~=2.0.0
sphinxcontrib-images~=1.0.1 sphinxcontrib-images~=1.0.1
sphinxcontrib-jquery~=4.1 sphinxcontrib-jquery~=4.1
sphinxcontrib-spelling~=8.0.2 sphinxcontrib-spelling~=8.0.2
+6 -6
View File
@@ -33,7 +33,7 @@ dependencies = [
"celery==5.6.*", "celery==5.6.*",
"chardet==5.2.*", "chardet==5.2.*",
"cryptography>=44.0.0", "cryptography>=44.0.0",
"css-inline==0.19.*", "css-inline==0.20.*",
"defusedcsv>=1.1.0", "defusedcsv>=1.1.0",
"dnspython==2.*", "dnspython==2.*",
"Django[argon2]==4.2.*,>=4.2.26", "Django[argon2]==4.2.*,>=4.2.26",
@@ -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.2", # 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.*",
@@ -73,14 +73,14 @@ dependencies = [
"packaging", "packaging",
"paypalrestsdk==1.13.*", "paypalrestsdk==1.13.*",
"paypal-checkout-serversdk==1.0.*", "paypal-checkout-serversdk==1.0.*",
"PyJWT==2.10.*", "PyJWT==2.11.*",
"phonenumberslite==9.0.*", "phonenumberslite==9.0.*",
"Pillow==12.1.*", "Pillow==12.1.*",
"pretix-plugin-build", "pretix-plugin-build",
"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.52.*",
"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',
+12 -1
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
@@ -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]
) )
+16 -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:
+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
+17 -8
View File
@@ -249,12 +249,17 @@ class GiftCardViewSet(viewsets.ModelViewSet):
def perform_create(self, serializer): def perform_create(self, serializer):
value = serializer.validated_data.pop('value') value = serializer.validated_data.pop('value')
inst = serializer.save(issuer=self.request.organizer) inst = serializer.save(issuer=self.request.organizer)
inst.transactions.create(value=value, acceptor=self.request.organizer)
inst.log_action( inst.log_action(
'pretix.giftcards.transaction.manual', action='pretix.giftcards.created',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk}) )
inst.transactions.create(value=value, acceptor=self.request.organizer)
inst.log_action(
action='pretix.giftcards.transaction.manual',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk, 'acceptor_id': self.request.organizer.id})
) )
@transaction.atomic() @transaction.atomic()
@@ -269,7 +274,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
testmode=serializer.instance.testmode) testmode=serializer.instance.testmode)
inst.log_action( inst.log_action(
'pretix.giftcards.modified', action='pretix.giftcards.modified',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data=self.request.data, data=self.request.data,
@@ -282,10 +287,10 @@ class GiftCardViewSet(viewsets.ModelViewSet):
diff = value - old_value diff = value - old_value
inst.transactions.create(value=diff, acceptor=self.request.organizer) inst.transactions.create(value=diff, acceptor=self.request.organizer)
inst.log_action( inst.log_action(
'pretix.giftcards.transaction.manual', action='pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'value': diff} data={'value': diff, 'acceptor_id': self.request.organizer.id}
) )
return inst return inst
@@ -309,10 +314,14 @@ class GiftCardViewSet(viewsets.ModelViewSet):
}, status=status.HTTP_409_CONFLICT) }, status=status.HTTP_409_CONFLICT)
gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer) gc.transactions.create(value=value, text=text, info=info, acceptor=self.request.organizer)
gc.log_action( gc.log_action(
'pretix.giftcards.transaction.manual', action='pretix.giftcards.transaction.manual',
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
data={'value': value, 'text': text} data={
'value': value,
'text': text,
'acceptor_id': self.request.organizer.id
}
) )
return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK) return Response(GiftCardSerializer(gc, context=self.get_serializer_context()).data, status=status.HTTP_200_OK)
+41
View File
@@ -174,6 +174,35 @@ class ParametrizedEventWebhookEvent(ParametrizedWebhookEvent):
} }
class ParametrizedGiftcardWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
giftcard = logentry.content_object
if not giftcard:
return None
return {
'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id,
'giftcard': giftcard.pk,
'action': logentry.action_type,
}
class ParametrizedGiftcardTransactionWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry):
giftcard = logentry.content_object
if not giftcard:
return None
return {
'notification_id': logentry.pk,
'issuer_id': logentry.organizer_id,
'acceptor_id': logentry.parsed_data.get('acceptor_id'),
'giftcard': giftcard.pk,
'action': logentry.action_type,
}
class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent): class ParametrizedVoucherWebhookEvent(ParametrizedWebhookEvent):
def build_payload(self, logentry: LogEntry): def build_payload(self, logentry: LogEntry):
@@ -433,6 +462,18 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.customer.anonymized', 'pretix.customer.anonymized',
_('Customer account anonymized'), _('Customer account anonymized'),
), ),
ParametrizedGiftcardWebhookEvent(
'pretix.giftcards.created',
_('Gift card added'),
),
ParametrizedGiftcardWebhookEvent(
'pretix.giftcards.modified',
_('Gift card modified'),
),
ParametrizedGiftcardTransactionWebhookEvent(
'pretix.giftcards.transaction.*',
_('Gift card used in transcation'),
)
) )
+9 -7
View File
@@ -39,7 +39,7 @@ from pretix.base.templatetags.rich_text import (
DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback, DEFAULT_CALLBACKS, EMAIL_RE, URL_RE, abslink_callback,
markdown_compile_email, truelink_callback, markdown_compile_email, truelink_callback,
) )
from pretix.helpers.format import SafeFormatter, format_map from pretix.helpers.format import FormattedString, SafeFormatter, format_map
from pretix.base.services.placeholders import ( # noqa from pretix.base.services.placeholders import ( # noqa
get_available_placeholders, PlaceholderContext get_available_placeholders, PlaceholderContext
@@ -141,6 +141,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
return markdown_compile_email(plaintext, context=context) return markdown_compile_email(plaintext, context=context)
def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position, context) -> str:
apply_format_map = not isinstance(plain_body, FormattedString)
body_md = self.compile_markdown(plain_body, context) body_md = self.compile_markdown(plain_body, context)
if context: if context:
linker = bleach.Linker( linker = bleach.Linker(
@@ -149,12 +150,13 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback], callbacks=DEFAULT_CALLBACKS + [truelink_callback, abslink_callback],
parse_email=True parse_email=True
) )
body_md = format_map( if apply_format_map:
body_md, body_md = format_map(
context=context, body_md,
mode=SafeFormatter.MODE_RICH_TO_HTML, context=context,
linkifier=linker mode=SafeFormatter.MODE_RICH_TO_HTML,
) linkifier=linker
)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL, 'site_url': settings.SITE_URL,
+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:
+7 -7
View File
@@ -890,18 +890,18 @@ class BaseQuestionsForm(forms.Form):
if not help_text: if not help_text:
if q.valid_date_min and q.valid_date_max: if q.valid_date_min and q.valid_date_max:
help_text = format_lazy( help_text = format_lazy(
'Please enter a date between {min} and {max}.', _('Please enter a date between {min} and {max}.'),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
) )
elif q.valid_date_min: elif q.valid_date_min:
help_text = format_lazy( help_text = format_lazy(
'Please enter a date no earlier than {min}.', _('Please enter a date no earlier than {min}.'),
min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"), min=date_format(q.valid_date_min, "SHORT_DATE_FORMAT"),
) )
elif q.valid_date_max: elif q.valid_date_max:
help_text = format_lazy( help_text = format_lazy(
'Please enter a date no later than {max}.', _('Please enter a date no later than {max}.'),
max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"), max=date_format(q.valid_date_max, "SHORT_DATE_FORMAT"),
) )
if initial and initial.answer: if initial and initial.answer:
@@ -939,18 +939,18 @@ class BaseQuestionsForm(forms.Form):
if not help_text: if not help_text:
if q.valid_datetime_min and q.valid_datetime_max: if q.valid_datetime_min and q.valid_datetime_max:
help_text = format_lazy( help_text = format_lazy(
'Please enter a date and time between {min} and {max}.', _('Please enter a date and time between {min} and {max}.'),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
) )
elif q.valid_datetime_min: elif q.valid_datetime_min:
help_text = format_lazy( help_text = format_lazy(
'Please enter a date and time no earlier than {min}.', _('Please enter a date and time no earlier than {min}.'),
min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"), min=date_format(q.valid_datetime_min, "SHORT_DATETIME_FORMAT"),
) )
elif q.valid_datetime_max: elif q.valid_datetime_max:
help_text = format_lazy( help_text = format_lazy(
'Please enter a date and time no later than {max}.', _('Please enter a date and time no later than {max}.'),
max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"), max=date_format(q.valid_datetime_max, "SHORT_DATETIME_FORMAT"),
) )
@@ -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." % (
+1 -1
View File
@@ -141,7 +141,7 @@ def get_babel_locale():
for locale in try_locales: for locale in try_locales:
if localedata.exists(locale): if localedata.exists(locale):
return locale return localedata.normalize_locale(locale)
return "en" return "en"
+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)
+6
View File
@@ -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 {
+26 -9
View File
@@ -21,6 +21,7 @@
# #
from typing import Optional from typing import Optional
from django.utils.translation import gettext_lazy as _
from django_countries.fields import Country from django_countries.fields import Country
from pretix.base.models import Invoice, InvoiceAddress from pretix.base.models import Invoice, InvoiceAddress
@@ -58,15 +59,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 +74,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()
@@ -106,6 +107,22 @@ class TransmissionType:
def transmission_info_to_form_data(self, transmission_info: dict) -> dict: def transmission_info_to_form_data(self, transmission_info: dict) -> dict:
return transmission_info return transmission_info
def describe_info(self, transmission_info: dict, country: Country, is_business: bool):
form_data = self.transmission_info_to_form_data(transmission_info)
data = []
visible_field_keys = self.invoice_address_form_fields_visible(country, is_business)
for k, f in self.invoice_address_form_fields.items():
if k not in visible_field_keys:
continue
v = form_data.get(k)
if v is True:
v = _("Yes")
elif v is False:
v = _("No")
if v:
data.append((f.label, v))
return data
def pdf_watermark(self) -> Optional[str]: def pdf_watermark(self) -> Optional[str]:
""" """
Return a watermark that should be rendered across the PDF file. Return a watermark that should be rendered across the PDF file.
+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
@@ -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
View File
@@ -130,6 +130,8 @@ class LoggingMixin:
organizer_id = self.event.organizer_id organizer_id = self.event.organizer_id
elif hasattr(self, 'organizer_id'): elif hasattr(self, 'organizer_id'):
organizer_id = self.organizer_id organizer_id = self.organizer_id
elif hasattr(self, 'issuer_id'):
organizer_id = self.issuer_id
if user and not user.is_authenticated: if user and not user.is_authenticated:
user = None user = None
+25 -1
View File
@@ -40,6 +40,7 @@ from i18nfield.fields import I18nCharField
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.banlist import banned from pretix.base.banlist import banned
from pretix.base.i18n import language
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.base.models.giftcards import GiftCardTransaction from pretix.base.models.giftcards import GiftCardTransaction
@@ -164,6 +165,28 @@ class Customer(LoggedModel):
self.attendee_profiles.all().delete() self.attendee_profiles.all().delete()
self.invoice_addresses.all().delete() self.invoice_addresses.all().delete()
def send_security_notice(self, message, email=None):
from pretix.base.services.mail import SendMailException, mail
from pretix.multidomain.urlreverse import build_absolute_uri
try:
with language(self.locale):
mail(
email or self.email,
self.organizer.settings.mail_subject_customer_security_notice,
self.organizer.settings.mail_text_customer_security_notice,
{
**self.get_email_context(),
'message': str(message),
'url': build_absolute_uri(self.organizer, 'presale:organizer.customer.index')
},
customer=self,
organizer=self.organizer,
locale=self.locale
)
except SendMailException:
pass # Already logged
@scopes_disabled() @scopes_disabled()
def assign_identifier(self): def assign_identifier(self):
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ23456789')
@@ -293,6 +316,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 +373,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
+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,
+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
+72 -100
View File
@@ -87,7 +87,7 @@ from pretix.base.timemachine import time_machine_now
from ...helpers import OF_SELF from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map from ...helpers.format import FormattedString, format_map
from ...helpers.names import build_name from ...helpers.names import build_name
from ...testutils.middleware import debugflags_var from ...testutils.middleware import debugflags_var
from ._transactions import ( from ._transactions import (
@@ -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,32 @@ 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) if not isinstance(subject, FormattedString):
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,
) )
except SendMailException: self.log_action(
raise log_entry_type,
else: user=user,
self.log_action( auth=auth,
log_entry_type, data={
user=user, 'subject': subject,
auth=auth, 'message': email_content,
data={ 'position': position.positionid if position else None,
'subject': subject, 'recipient': recipient,
'message': email_content, 'invoices': [i.pk for i in invoices] if invoices else [],
'position': position.positionid if position else None, 'attach_tickets': attach_tickets,
'recipient': recipient, 'attach_ical': attach_ical,
'invoices': [i.pk for i in invoices] if invoices else [], 'attach_other_files': attach_other_files,
'attach_tickets': attach_tickets, 'attach_cached_files': [cf.filename for cf in attach_cached_files] if attach_cached_files else [],
'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 +1670,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 +2019,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 +2900,40 @@ 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) if not isinstance(subject, FormattedString):
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,
) )
except SendMailException: self.order.log_action(
raise log_entry_type,
else: user=user,
self.order.log_action( auth=auth,
log_entry_type, data={
user=user, 'subject': subject,
auth=auth, 'message': email_content,
data={ 'recipient': recipient,
'subject': subject, 'invoices': [i.pk for i in invoices] if invoices else [],
'message': email_content, 'attach_tickets': attach_tickets,
'recipient': recipient, 'attach_ical': attach_ical,
'invoices': [i.pk for i in invoices] if invoices else [], 'attach_other_files': attach_other_files,
'attach_tickets': attach_tickets, 'attach_cached_files': [],
'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 +3460,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
@@ -3529,18 +3509,10 @@ class InvoiceAddress(models.Model):
def describe_transmission(self): def describe_transmission(self):
from pretix.base.invoicing.transmission import transmission_types from pretix.base.invoicing.transmission import transmission_types
data = [] data = []
t, __ = transmission_types.get(identifier=self.transmission_type) t, __ = transmission_types.get(identifier=self.transmission_type)
data.append((_("Transmission type"), t.public_name)) data.append((_("Transmission type"), t.public_name))
form_data = t.transmission_info_to_form_data(self.transmission_info or {}) if self.transmission_info:
for k, f in t.invoice_address_form_fields.items(): data += t.describe_info(self.transmission_info, self.country, self.is_business)
v = form_data.get(k)
if v is True:
v = _("Yes")
elif v is False:
v = _("No")
if v:
data.append((f.label, v))
return data return data
+28 -29
View File
@@ -34,7 +34,7 @@ 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 pretix.helpers import OF_SELF
from ...helpers.format import format_map from ...helpers.format import format_map
@@ -159,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
@@ -191,6 +192,7 @@ class WaitingListEntry(LoggedModel):
with transaction.atomic(): with transaction.atomic():
locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk) locked_wle = WaitingListEntry.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
locked_wle.event = event
if locked_wle.voucher: if locked_wle.voucher:
raise WaitingListException(_('A voucher has already been sent to this person.')) raise WaitingListException(_('A voucher has already been sent to this person.'))
e = locked_wle.email e = locked_wle.email
@@ -227,6 +229,7 @@ class WaitingListEntry(LoggedModel):
locked_wle.save() locked_wle.save()
self.refresh_from_db() 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(
@@ -269,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):
+17 -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):
@@ -1646,6 +1646,13 @@ class GiftCardPayment(BasePaymentProvider):
'transaction_id': trans.pk, 'transaction_id': trans.pk,
} }
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case) payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
gc.log_action(
action='pretix.giftcards.transaction.payment',
data={
'value': trans.value,
'acceptor_id': self.event.organizer.id
}
)
except PaymentException as e: except PaymentException as e:
payment.fail(info={'error': str(e)}) payment.fail(info={'error': str(e)})
raise e raise e
@@ -1670,6 +1677,14 @@ class GiftCardPayment(BasePaymentProvider):
'transaction_id': trans.pk, 'transaction_id': trans.pk,
} }
refund.done() refund.done()
gc.log_action(
action='pretix.giftcards.transaction.refund',
data={
'value': refund.amount,
'acceptor_id': self.event.organizer.id,
'text': refund.comment,
}
)
@receiver(register_payment_providers, dispatch_uid="payment_free") @receiver(register_payment_providers, dispatch_uid="payment_free")
+1 -1
View File
@@ -65,7 +65,7 @@ def get_all_plugins(*, event=None, organizer=None) -> List[type]:
if app.name in settings.PRETIX_PLUGINS_EXCLUDE: if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue continue
level = getattr(app, "level", PLUGIN_LEVEL_EVENT) level = getattr(meta, "level", PLUGIN_LEVEL_EVENT)
if level == PLUGIN_LEVEL_EVENT: if level == PLUGIN_LEVEL_EVENT:
if event and hasattr(app, 'is_available'): if event and hasattr(app, 'is_available'):
if not app.is_available(event): if not app.is_available(event):
+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,))
+2 -2
View File
@@ -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()
+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,
}) })
+186 -146
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,
) )
@@ -248,6 +247,15 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
for gc in position.issued_gift_cards.all(): for gc in position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer) gc.transactions.create(value=position.price, order=order, acceptor=order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=user,
auth=auth,
data={
'value': position.price,
'acceptor_id': order.event.organizer.id
}
)
break break
for m in position.granted_memberships.all(): for m in position.granted_memberships.all():
@@ -438,33 +446,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 +503,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
@@ -558,6 +557,14 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
) )
else: else:
gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer) gc.transactions.create(value=-position.price, order=order, acceptor=order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=user,
data={
'value': -position.price,
'acceptor_id': order.event.organizer.id,
}
)
for m in position.granted_memberships.all(): for m in position.granted_memberships.all():
m.canceled = True m.canceled = True
@@ -660,14 +667,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:
@@ -968,7 +972,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 +999,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 +1112,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 +1474,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 +1538,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 +1556,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 +1568,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 +1627,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 +1791,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 +2082,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 +2151,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
@@ -2461,6 +2451,15 @@ class OrderChangeManager:
)) ))
else: else:
gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer) gc.transactions.create(value=-position.price, order=self.order, acceptor=self.order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=self.user,
auth=self.auth,
data={
'value': -position.price,
'acceptor_id': self.order.event.organizer.id
}
)
for m in position.granted_memberships.with_usages().all(): for m in position.granted_memberships.with_usages().all():
m.canceled = True m.canceled = True
@@ -2478,6 +2477,15 @@ class OrderChangeManager:
)) ))
else: else:
gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer) gc.transactions.create(value=-opa.position.price, order=self.order, acceptor=self.order.event.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
user=self.user,
auth=self.auth,
data={
'value': -opa.position.price,
'acceptor_id': self.order.event.organizer.id
}
)
for m in opa.granted_memberships.with_usages().all(): for m in opa.granted_memberships.with_usages().all():
m.canceled = True m.canceled = True
@@ -2640,6 +2648,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 +2706,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 +2731,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 +2851,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=[
@@ -3131,7 +3154,10 @@ def _try_auto_refund(order, auto_refund=True, manual_refund=False, allow_partial
customer=order.customer, customer=order.customer,
testmode=order.testmode testmode=order.testmode
) )
giftcard.log_action('pretix.giftcards.created', data={}) giftcard.log_action(
action='pretix.giftcards.created',
data={}
)
r = order.refunds.create( r = order.refunds.create(
order=order, order=order,
payment=None, payment=None,
@@ -3269,8 +3295,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 +3325,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):
@@ -3414,7 +3444,17 @@ def signal_listener_issue_giftcards(sender: Event, order: Order, **kwargs):
currency=sender.currency, issued_in=p, testmode=order.testmode, currency=sender.currency, issued_in=p, testmode=order.testmode,
expires=sender.organizer.default_gift_card_expiry, expires=sender.organizer.default_gift_card_expiry,
) )
gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer) gc.log_action(
action='pretix.giftcards.created',
)
trans = gc.transactions.create(value=p.price - issued, order=order, acceptor=sender.organizer)
gc.log_action(
action='pretix.giftcards.transaction.manual',
data={
'value': trans.value,
'acceptor_id': order.event.organizer.id,
}
)
any_giftcards = True any_giftcards = True
p.secret = gc.secret p.secret = gc.secret
p.save(update_fields=['secret']) p.save(update_fields=['secret'])
+9 -2
View File
@@ -211,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
@@ -226,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
+36 -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
) )
@@ -1225,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,
@@ -2938,6 +2948,28 @@ If you did not request a new password, please ignore this email.
Best regards, Best regards,
Your {organizer} team""")) # noqa: W291
},
'mail_subject_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("Changes to your account at {organizer}")),
},
'mail_text_customer_security_notice': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(gettext_noop("""Hello {name},
the following change has been made to your account at {organizer}:
{message}
You can review and change your account settings here:
{url}
If this change was not performed by you, please contact us immediately.
Best regards,
Your {organizer} team""")) # noqa: W291 Your {organizer} team""")) # noqa: W291
}, },
'smtp_use_custom': { 'smtp_use_custom': {
@@ -3927,7 +3959,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"
@@ -3935,6 +3967,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.
""" """
-3
View File
@@ -8,9 +8,6 @@
<h1>{% trans "Not found" %}</h1> <h1>{% trans "Not found" %}</h1>
<p>{% trans "I'm afraid we could not find the the resource you requested." %}</p> <p>{% trans "I'm afraid we could not find the the resource you requested." %}</p>
<p>{{ exception }}</p> <p>{{ exception }}</p>
<p class="links">
<a id='goback' href='#'>{% trans "Take a step back" %}</a>
</p>
{% if request.user.is_staff and not staff_session %} {% if request.user.is_staff and not staff_session %}
<form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post"> <form action="{% url 'control:user.sudo' %}?next={{ request.path|add:"?"|add:request.GET.urlencode|urlencode }}" method="post">
<p> <p>
+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))
+1
View File
@@ -95,6 +95,7 @@ class OrganizerSlugBanlistValidator(BanlistValidator):
'csp_report', 'csp_report',
'widget', 'widget',
'lead', 'lead',
'scheduling',
] ]
+16 -3
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import pycountry import pycountry
from django.conf import settings
from django.http import JsonResponse from django.http import JsonResponse
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.translation import gettext, pgettext, pgettext_lazy from django.utils.translation import gettext, pgettext, pgettext_lazy
@@ -29,6 +30,7 @@ from django_scopes import scope
from pretix.base.addressvalidation import ( from pretix.base.addressvalidation import (
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED, COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED,
) )
from pretix.base.i18n import language
from pretix.base.invoicing.transmission import get_transmission_types from pretix.base.invoicing.transmission import get_transmission_types
from pretix.base.models import Organizer from pretix.base.models import Organizer
from pretix.base.models.tax import VAT_ID_COUNTRIES from pretix.base.models.tax import VAT_ID_COUNTRIES
@@ -82,14 +84,14 @@ 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,
} }
def address_form(request): def _address_form(request):
cc = request.GET.get("country", "DE") cc = request.GET.get("country", "DE")
info = _info(cc) info = _info(cc)
@@ -109,7 +111,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:
@@ -157,4 +159,15 @@ def address_form(request):
# The help text explains that it is optional, so we want to hide that if it is required # The help text explains that it is optional, so we want to hide that if it is required
info["vat_id"]["helptext_visible"] = False info["vat_id"]["helptext_visible"] = False
return info
def address_form(request):
locale = request.GET.get('locale')
if locale in dict(settings.LANGUAGES):
with language(locale):
info = _address_form(request)
else:
info = _address_form(request)
return JsonResponse(info) return JsonResponse(info)
+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': 'created',
'-date': '-created',
}
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
+16
View File
@@ -585,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,
@@ -634,6 +635,16 @@ class MailSettingsForm(SettingsForm):
required=False, required=False,
widget=I18nMarkdownTextarea, widget=I18nMarkdownTextarea,
) )
mail_subject_customer_security_notice = I18nFormField(
label=_("Subject"),
required=False,
widget=I18nTextInput,
)
mail_text_customer_security_notice = I18nFormField(
label=_("Text"),
required=False,
widget=I18nMarkdownTextarea,
)
base_context = { base_context = {
'mail_text_customer_registration': ['customer', 'url'], 'mail_text_customer_registration': ['customer', 'url'],
@@ -642,6 +653,8 @@ class MailSettingsForm(SettingsForm):
'mail_subject_customer_email_change': ['customer', 'url'], 'mail_subject_customer_email_change': ['customer', 'url'],
'mail_text_customer_reset': ['customer', 'url'], 'mail_text_customer_reset': ['customer', 'url'],
'mail_subject_customer_reset': ['customer', 'url'], 'mail_subject_customer_reset': ['customer', 'url'],
'mail_text_customer_security_notice': ['customer', 'url', 'message'],
'mail_subject_customer_security_notice': ['customer', 'url', 'message'],
} }
def _get_sample_context(self, base_parameters): def _get_sample_context(self, base_parameters):
@@ -655,6 +668,9 @@ class MailSettingsForm(SettingsForm):
'presale:organizer.customer.activate' 'presale:organizer.customer.activate'
) + '?token=' + get_random_string(30) ) + '?token=' + get_random_string(30)
if 'message' in base_parameters:
placeholders['message'] = _('Your password has been changed.')
if 'customer' in base_parameters: if 'customer' in base_parameters:
placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe') placeholders['name'] = pgettext_lazy('person_name_sample', 'John Doe')
name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme] name_scheme = PERSON_NAME_SCHEMES[self.organizer.settings.name_scheme]
+10
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.'),
@@ -793,6 +801,8 @@ class CoreUserImpersonatedLogEntryType(UserImpersonatedLogEntryType):
'pretix.giftcards.created': _('The gift card has been created.'), 'pretix.giftcards.created': _('The gift card has been created.'),
'pretix.giftcards.modified': _('The gift card has been changed.'), 'pretix.giftcards.modified': _('The gift card has been changed.'),
'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'), 'pretix.giftcards.transaction.manual': _('A manual transaction has been performed.'),
'pretix.giftcards.transaction.payment': _('A payment has been performed.'),
'pretix.giftcards.transaction.refund': _('A refund has been performed. '),
'pretix.team.token.created': _('The token "{name}" has been created.'), 'pretix.team.token.created': _('The token "{name}" has been created.'),
'pretix.team.token.deleted': _('The token "{name}" has been revoked.'), 'pretix.team.token.deleted': _('The token "{name}" has been revoked.'),
'pretix.event.checkin.reset': _('The check-in and print log state has been reset.') 'pretix.event.checkin.reset': _('The check-in and print log state has been reset.')
+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 %}
@@ -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 %}
@@ -65,6 +65,9 @@
{% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %} {% blocktrans asvar title_reset %}Customer account password reset{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %} {% include "pretixcontrol/event/mail_settings_fragment.html" with pid="reset" title=title_reset items="mail_subject_customer_reset,mail_text_customer_reset" %}
{% blocktrans asvar title_security_notice %}Customer account security notification{% endblocktrans %}
{% include "pretixcontrol/event/mail_settings_fragment.html" with pid="security_notice" title=title_security_notice items="mail_subject_customer_security_notice,mail_text_customer_security_notice" %}
</div> </div>
</fieldset> </fieldset>
</div> </div>
@@ -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>{{ 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.")
+194
View File
@@ -0,0 +1,194 @@
#
# 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 email.header import decode_header, make_header
from email.utils import parseaddr
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()
from_name, from_email = parseaddr(self.object.sender)
if from_name:
from_name = make_header(decode_header(from_name))
ctx['sender'] = "{} <{}>".format(from_name, from_email) if from_name else from_email
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,
})
+76 -79
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:
@@ -1230,7 +1226,11 @@ class OrderRefundView(OrderView):
customer=order.customer, customer=order.customer,
testmode=order.testmode testmode=order.testmode
) )
giftcard.log_action('pretix.giftcards.created', user=self.request.user, data={}) giftcard.log_action(
action='pretix.giftcards.created',
user=self.request.user,
data={}
)
refunds.append(OrderRefund( refunds.append(OrderRefund(
order=order, order=order,
payment=None, payment=None,
@@ -1538,9 +1538,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 +1738,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 +1776,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 +2424,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 +2488,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 +2661,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 +2705,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 +2779,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 +2827,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 +2839,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 +2872,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:
+94 -58
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):
@@ -1669,9 +1667,12 @@ class GiftCardAcceptanceInviteView(OrganizerDetailViewMixin, OrganizerPermission
active=False, active=False,
) )
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.giftcards.acceptance.acceptor.invited', action='pretix.giftcards.acceptance.acceptor.invited',
data={'acceptor': form.cleaned_data['acceptor'].slug, data={
'reusable_media': form.cleaned_data['reusable_media']}, 'acceptor': form.cleaned_data['acceptor'].slug,
'issuer': self.request.organizer.slug,
'reusable_media': form.cleaned_data['reusable_media']
},
user=self.request.user user=self.request.user
) )
messages.success(self.request, _('The selected organizer has been invited.')) messages.success(self.request, _('The selected organizer has been invited.'))
@@ -1707,8 +1708,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
).delete() ).delete()
if done: if done:
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.giftcards.acceptance.acceptor.removed', action='pretix.giftcards.acceptance.acceptor.removed',
data={'acceptor': request.POST.get("delete_acceptor")}, data={
'acceptor': request.POST.get("delete_acceptor"),
'issuer': self.request.organizer.slug
},
user=request.user user=request.user
) )
messages.success(self.request, _('The selected connection has been removed.')) messages.success(self.request, _('The selected connection has been removed.'))
@@ -1718,8 +1722,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
).delete() ).delete()
if done: if done:
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.giftcards.acceptance.issuer.removed', action='pretix.giftcards.acceptance.issuer.removed',
data={'issuer': request.POST.get("delete_acceptor")}, data={
'issuer': request.POST.get("delete_acceptor"),
'acceptor': self.request.organizer.slug
},
user=request.user user=request.user
) )
messages.success(self.request, _('The selected connection has been removed.')) messages.success(self.request, _('The selected connection has been removed.'))
@@ -1729,8 +1736,11 @@ class GiftCardAcceptanceListView(OrganizerDetailViewMixin, OrganizerPermissionRe
).update(active=True) ).update(active=True)
if done: if done:
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.giftcards.acceptance.issuer.accepted', action='pretix.giftcards.acceptance.issuer.accepted',
data={'issuer': request.POST.get("accept_issuer")}, data={
'issuer': request.POST.get("accept_issuer"),
'acceptor': self.request.organizer.slug
},
user=request.user user=request.user
) )
messages.success(self.request, _('The selected connection has been accepted.')) messages.success(self.request, _('The selected connection has been accepted.'))
@@ -1836,10 +1846,11 @@ class GiftCardDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
acceptor=request.organizer, acceptor=request.organizer,
) )
self.object.log_action( self.object.log_action(
'pretix.giftcards.transaction.manual', action='pretix.giftcards.transaction.manual',
data={ data={
'value': value, 'value': value,
'text': request.POST.get('text') 'text': request.POST.get('text'),
'acceptor_id': self.request.organizer.id
}, },
user=self.request.user, user=self.request.user,
) )
@@ -1888,15 +1899,23 @@ class GiftCardCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
messages.success(self.request, _('The gift card has been created and can now be used.')) messages.success(self.request, _('The gift card has been created and can now be used.'))
form.instance.issuer = self.request.organizer form.instance.issuer = self.request.organizer
super().form_valid(form) super().form_valid(form)
form.instance.transactions.create( form.instance.log_action(
acceptor=self.request.organizer, action='pretix.giftcards.created',
value=form.cleaned_data['value'] user=self.request.user,
) )
form.instance.log_action('pretix.giftcards.created', user=self.request.user, data={})
if form.cleaned_data['value']: if form.cleaned_data['value']:
form.instance.log_action('pretix.giftcards.transaction.manual', user=self.request.user, data={ form.instance.transactions.create(
'value': form.cleaned_data['value'] acceptor=self.request.organizer,
}) value=form.cleaned_data['value']
)
form.instance.log_action(
action='pretix.giftcards.transaction.manual',
user=self.request.user,
data={
'value': form.cleaned_data['value'],
'acceptor_id': self.request.organizer.id
}
)
return redirect(reverse( return redirect(reverse(
'control:organizer.giftcard', 'control:organizer.giftcard',
kwargs={ kwargs={
@@ -1924,7 +1943,11 @@ class GiftCardUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
def form_valid(self, form): def form_valid(self, form):
messages.success(self.request, _('The gift card has been changed.')) messages.success(self.request, _('The gift card has been changed.'))
super().form_valid(form) super().form_valid(form)
form.instance.log_action('pretix.giftcards.modified', user=self.request.user, data=dict(form.cleaned_data)) form.instance.log_action(
action='pretix.giftcards.modified',
user=self.request.user,
data=dict(form.cleaned_data)
)
return redirect(reverse( return redirect(reverse(
'control:organizer.giftcard', 'control:organizer.giftcard',
kwargs={ kwargs={
@@ -1943,8 +1966,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 +2070,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 +2141,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 +2188,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 +2201,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 +2242,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:
@@ -3014,6 +3049,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"
+25 -2
View File
@@ -22,6 +22,7 @@
import logging import logging
from string import Formatter from string import Formatter
from django.core.exceptions import SuspiciousOperation
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -37,6 +38,17 @@ class PlainHtmlAlternativeString:
return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')" return f"PlainHtmlAlternativeString('{self.plain}', '{self.html}')"
class FormattedString(str):
"""
A str subclass that has been specifically marked as "already formatted" for email rendering
purposes to avoid duplicate formatting.
"""
__slots__ = ()
def __str__(self):
return self
class SafeFormatter(Formatter): class SafeFormatter(Formatter):
""" """
Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and Customized version of ``str.format`` that (a) behaves just like ``str.format_map`` and
@@ -77,8 +89,19 @@ class SafeFormatter(Formatter):
# Ignore format_spec # Ignore format_spec
return super().format_field(self._prepare_value(value), '') return super().format_field(self._prepare_value(value), '')
def convert_field(self, value, conversion):
# Ignore any conversions
if conversion is None:
return value
else:
return str(value)
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None):
def format_map(template, context, raise_on_missing=False, mode=SafeFormatter.MODE_RICH_TO_PLAIN, linkifier=None) -> FormattedString:
if isinstance(template, FormattedString):
raise SuspiciousOperation("Calling format_map() on an already formatted string is likely unsafe.")
if not isinstance(template, str): if not isinstance(template, str):
template = str(template) template = str(template)
return SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template) return FormattedString(
SafeFormatter(context, raise_on_missing, mode=mode, linkifier=linkifier).format(template)
)
+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

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