Compare commits

..
Author SHA1 Message Date
Raphael Michel 5babdc679d Drop nullability on Order.organizer and OrderPosition.organizer 2024-07-01 11:30:18 +02:00
Raphael Michel e691afdd34 Add auto-generated migration 2024-07-01 11:26:55 +02:00
Raphael Michel add90b08ec Export: Fix sales channels in JSON export 2024-07-01 11:22:18 +02:00
Raphael Michel 4539c6523b Fix failing tests 2024-07-01 09:24:42 +02:00
Raphael Michel 3453818c16 Fix email layout preview (PRETIXEU-AB9) 2024-07-01 08:56:36 +02:00
Raphael Michel e5725d6d33 Fix statistics view (PRETIXEU-AB5) 2024-07-01 08:51:23 +02:00
Raphael Michel 0cda1aeaaf Remove nl_BE again 2024-06-30 23:44:55 +02:00
Raphael Michel 769c451bcb Docs: Fix incorrect argument name 2024-06-30 23:34:17 +02:00
Raphael MichelandRaphael Michel 5b60928205 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (231 of 231 strings)

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

powered by weblate
2024-06-30 23:13:36 +02:00
Raphael MichelandRaphael Michel b05f3a449d Translations: Update German
Currently translated at 100.0% (231 of 231 strings)

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

powered by weblate
2024-06-30 23:13:36 +02:00
Raphael MichelandRaphael Michel 3262f2ba84 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5690 of 5690 strings)

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

powered by weblate
2024-06-30 23:13:36 +02:00
Raphael MichelandRaphael Michel fe9d8e58a1 Translations: Update German
Currently translated at 100.0% (5690 of 5690 strings)

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

powered by weblate
2024-06-30 23:13:36 +02:00
Raphael Michel 6706dbb4db Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-06-30 20:56:24 +02:00
Raphael MichelandGitHub 87632ff8c5 Merge pull request #4274 from pretix-translations/weblate-pretix-pretix 2024-06-30 20:55:22 +02:00
Weblate 2278dbdc4a Merge remote-tracking branch 'origin/master' 2024-06-30 20:54:15 +02:00
Raphael Michel e4e6cc0fcc Revert "Update po files"
This reverts commit ea73977c37.
2024-06-30 20:53:13 +02:00
Raphael Michel ea73977c37 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-06-30 20:44:34 +02:00
4fb5c6bef0 New implementation of sales channels (#4111)
Co-authored-by: Martin Gross <gross@rami.io>
2024-06-30 19:24:30 +02:00
Raphael Michel 95511b0330 Remove X-XSS-Protection, no longer supported by any browser 2024-06-29 19:25:34 +02:00
Raphael MichelandGitHub 3340599aec Phase out giropay support (#4266) 2024-06-28 16:17:45 +02:00
e9a52d07d1 Update translations (#4265)
Co-authored-by: Nikolai <nikolai@lengefeldt.de>
Co-authored-by: Anarion Dunedain <anarion80@gmail.com>
Co-authored-by: Erik Löfman <erik@disruptiveventures.se>
Co-authored-by: Kristian Feldsam <feldsam@gmail.com>
2024-06-28 12:54:45 +02:00
Erik LöfmanandWeblate 0a00a35ab1 Translations: Update Swedish
Currently translated at 57.1% (3237 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
NikolaiandWeblate 24d8dc6c76 Translations: Update Danish
Currently translated at 31.3% (1777 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Erik LöfmanandWeblate a9d48eaafe Translations: Update Swedish
Currently translated at 87.0% (201 of 231 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Erik LöfmanandWeblate bb959fa494 Translations: Update Swedish
Currently translated at 56.6% (3210 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Anarion DunedainandWeblate 6126309429 Translations: Update Polish
Currently translated at 100.0% (231 of 231 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Anarion DunedainandWeblate 29b49ca82f Translations: Update Polish
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
NikolaiandWeblate 7b7b83cb3e Translations: Update Danish
Currently translated at 31.3% (1774 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Erik LöfmanandWeblate 6bb2b3425d Translations: Update Swedish
Currently translated at 52.8% (2991 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Anarion DunedainandWeblate 182d30ffb7 Translations: Update Polish
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Anarion DunedainandWeblate 844c291575 Translations: Update Polish
Currently translated at 97.9% (5550 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Kristian FeldsamandWeblate ecdb1a8e09 Translations: Update Slovak
Currently translated at 2.5% (6 of 231 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Kristian FeldsamandWeblate 6c6e5d5af6 Translations: Update Slovak
Currently translated at 93.1% (5276 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Anarion DunedainandWeblate c33853173b Translations: Update Polish
Currently translated at 91.5% (5186 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Erik LöfmanandWeblate 5f90bf80b8 Translations: Update Swedish
Currently translated at 52.7% (2985 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:58 +02:00
Anarion DunedainandWeblate 0ba246846b Translations: Update Polish
Currently translated at 85.7% (4859 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:57 +02:00
Erik LöfmanandWeblate 88ea04551e Translations: Update Swedish
Currently translated at 45.9% (2602 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:57 +02:00
Anarion DunedainandWeblate 2bfd8e17b0 Translations: Update Polish
Currently translated at 78.9% (4469 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:57 +02:00
NikolaiandWeblate 1379e5f723 Translations: Update Danish
Currently translated at 31.1% (1766 of 5664 strings)

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

powered by weblate
2024-06-28 12:45:57 +02:00
Martin Gross 9fcaa3d730 Languages: On second thought, make Swedish a regular community-language. 2024-06-28 12:45:52 +02:00
Martin Gross 22d92025c9 Languages: Adding Swedish to incubating languages 2024-06-28 12:33:32 +02:00
Richard Schreiber 2b84c8d7a7 Fix CSS vars naming prefix 2024-06-28 09:36:26 +02:00
Richard SchreiberandGitHub 25ba2f1145 Add signal seatingframe_html_head (#4270) 2024-06-27 10:46:26 +02:00
Richard SchreiberandGitHub ae8ff60964 Fix missing CSS in seatingframe (#4269) 2024-06-27 10:20:09 +02:00
Raphael Michel 9fe6916ab5 Item index: use internal category names again 2024-06-26 15:25:04 +02:00
Raphael Michel 634263f1ba Item form: Validate empty dynamic validity 2024-06-26 09:44:49 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
67265e94a0 Update importlib-metadata requirement from ==7.* to ==8.* (#4264)
Updates the requirements on [importlib-metadata](https://github.com/python/importlib_metadata) to permit the latest version.
- [Release notes](https://github.com/python/importlib_metadata/releases)
- [Changelog](https://github.com/python/importlib_metadata/blob/main/NEWS.rst)
- [Commits](https://github.com/python/importlib_metadata/compare/v7.0.0...v8.0.0)

---
updated-dependencies:
- dependency-name: importlib-metadata
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 09:27:04 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
0fa2e9b5dd Update webauthn requirement from ==2.1.* to ==2.2.* (#4263)
Updates the requirements on [webauthn](https://github.com/duo-labs/py_webauthn) to permit the latest version.
- [Release notes](https://github.com/duo-labs/py_webauthn/releases)
- [Changelog](https://github.com/duo-labs/py_webauthn/blob/master/CHANGELOG.md)
- [Commits](https://github.com/duo-labs/py_webauthn/compare/v2.1.0...v2.2.0)

---
updated-dependencies:
- dependency-name: webauthn
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2024-06-26 09:26:58 +02:00
Raphael Michel c99d93a078 CSS: Fix minor bugs 2024-06-25 15:55:48 +02:00
Raphael Michel 9e20fac0da CSS: Fix handling of white background 2024-06-25 15:22:16 +02:00
Raphael Michel 3e4ccc53be Fix primary color in control 2024-06-25 14:28:47 +02:00
Mira Weller ce88dfa530 add missing semicolon 2024-06-25 14:23:27 +02:00
f0a06cd9fe Replace SCSS compilation with CSS variables (#4191)
* Replace SCSS compilation with CSS variables

* Update tests

* Update src/pretix/presale/style.py

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

* Update src/pretix/presale/context.py

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

* Update src/pretix/presale/views/widget.py

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

* Update src/pretix/presale/context.py

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

* Update src/pretix/static/pretixbase/scss/_variables.scss

Co-authored-by: Richard Schreiber <schreiber@rami.io>

* Last minor changes

* Rename file

---------

Co-authored-by: Mira <weller@rami.io>
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-06-25 13:01:20 +02:00
Erik LöfmanandRaphael Michel 7672e6274d Translations: Update Swedish
Currently translated at 39.4% (2232 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Anarion DunedainandRaphael Michel 061f578b29 Translations: Update Polish
Currently translated at 76.7% (4346 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Erik LöfmanandRaphael Michel 79f8501a09 Translations: Update Swedish
Currently translated at 37.8% (2145 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Erik LöfmanandRaphael Michel c5237b5021 Translations: Update Swedish
Currently translated at 36.3% (2057 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Anarion DunedainandRaphael Michel 0d6f7e74a3 Translations: Update Polish
Currently translated at 76.4% (4332 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Reece NeedhamandRaphael Michel 21bd4a86a7 Translations: Update Spanish
Currently translated at 100.0% (231 of 231 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Reece NeedhamandRaphael Michel 750f641018 Translations: Update Spanish
Currently translated at 87.6% (4966 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Felix HartnagelandRaphael Michel 2a385d14c4 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
Felix HartnagelandRaphael Michel 6a7ab1bdf5 Translations: Update German
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-25 13:01:13 +02:00
MiraandGitHub a73c4ad937 Improve List Sorting UI (#4215)
Improve product list UI (allow move between categories, more useful columns and links)
and hide "move up/down" arrows in lists by default if drag-drop is available
2024-06-25 12:54:11 +02:00
Raphael Michel 043e2eb9cf Order denial email: Use correct language for context 2024-06-25 11:57:42 +02:00
Raphael Michel c0fb93ea3b Waiting list: Add additional explanation (Z#23157006) 2024-06-25 11:31:16 +02:00
4f9297e7d8 Show minimal check-in status in order export (Z#23154920) (#4223)
* Show minimal check-in status in order export (Z#23154920)

* Update src/pretix/helpers/database.py

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

* Review note

---------

Co-authored-by: Mira <weller@rami.io>
2024-06-24 17:34:10 +02:00
Erik LöfmanandMira 70b48fdd4b Translations: Update Swedish
Currently translated at 35.2% (1994 of 5664 strings)

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

powered by weblate
2024-06-24 17:20:24 +02:00
Anarion DunedainandMira e7b5317431 Translations: Update Polish
Currently translated at 75.1% (4259 of 5664 strings)

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

powered by weblate
2024-06-24 17:20:24 +02:00
MiraandMira 63ef2e70e2 Translations: Update Spanish
Currently translated at 75.3% (174 of 231 strings)

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

powered by weblate
2024-06-24 17:20:24 +02:00
MiraandMira c4db2a48b6 Translations: Update Spanish
Currently translated at 87.6% (4964 of 5664 strings)

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

powered by weblate
2024-06-24 17:20:24 +02:00
Reece NeedhamandMira de255b021e Translations: Update Spanish
Currently translated at 87.6% (4964 of 5664 strings)

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

powered by weblate
2024-06-24 17:20:24 +02:00
Raphael Michel d3fce71b7f Bump version to 2024.7.0.dev0 2024-06-24 11:05:53 +02:00
Raphael Michel 37dea068ce Bump version to 2024.6.0 2024-06-24 11:04:38 +02:00
Raphael MichelandRaphael Michel b5ef49cd3c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-24 11:00:19 +02:00
Raphael MichelandRaphael Michel 245c5972c6 Translations: Update German
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-24 11:00:19 +02:00
Raphael MichelandRaphael Michel 6597977752 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-24 11:00:19 +02:00
Raphael MichelandRaphael Michel 580137577e Translations: Update German
Currently translated at 100.0% (5664 of 5664 strings)

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

powered by weblate
2024-06-24 11:00:19 +02:00
Raphael Michel a09550ce02 Translations:Add TWINT to wordlist 2024-06-24 10:55:26 +02:00
Raphael Michel 1b444b780d Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2024-06-24 10:20:56 +02:00
Anarion DunedainandRaphael Michel f41d8bb761 Translations: Update Polish
Currently translated at 68.3% (3850 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Maciej SzymczakandRaphael Michel 365dbe7a14 Translations: Update Polish
Currently translated at 63.5% (3582 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Anarion DunedainandRaphael Michel bf6078efb6 Translations: Update Polish
Currently translated at 63.5% (3582 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Anarion DunedainandRaphael Michel 695b9a2ed6 Translations: Update Polish
Currently translated at 63.5% (3579 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Anarion DunedainandRaphael Michel 25a15069ed Translations: Update Polish
Currently translated at 51.8% (2921 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Anarion DunedainandRaphael Michel 11a4ea7b77 Translations: Update Polish
Currently translated at 100.0% (229 of 229 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Anarion DunedainandRaphael Michel 4e5e7df201 Translations: Update Polish
Currently translated at 49.5% (2789 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Raphael MichelandRaphael Michel 8f515aa327 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (229 of 229 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Tinna SandströmandRaphael Michel dbc7fda2f8 Translations: Update Swedish
Currently translated at 27.0% (1522 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Erik LöfmanandRaphael Michel 5e686674ae Translations: Update Swedish
Currently translated at 27.0% (1522 of 5634 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Raphael MichelandRaphael Michel 5660cd7f93 Translations: Update German
Currently translated at 100.0% (229 of 229 strings)

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

powered by weblate
2024-06-24 09:24:57 +02:00
Raphael Michel a7a33ed165 Voucher bulk form: Strip spaces from email addresses before validation 2024-06-20 17:16:21 +02:00
Richard SchreiberandGitHub 9ffdf979f4 PDF: ignore outline, annots, etc. when merging background-pdf (#4249) 2024-06-20 12:07:04 +02:00
7338381e58 Stripe: Support for TWINT (#4216)
* Stripe: Support for TWINT

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

Co-authored-by: Richard Schreiber <schreiber@rami.io>

---------

Co-authored-by: Richard Schreiber <schreiber@rami.io>
2024-06-20 10:22:43 +02:00
350 changed files with 161222 additions and 140493 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ if [ "$1" == "taskworker" ]; then
fi fi
if [ "$1" == "upgrade" ]; then if [ "$1" == "upgrade" ]; then
exec python3 -m pretix updatestyles exec python3 -m pretix updateassets
fi fi
exec python3 -m pretix "$@" exec python3 -m pretix "$@"
+1 -1
View File
@@ -19,7 +19,7 @@ You can use ``pip`` to update pretix directly to the development branch. Then, u
(venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix" (venv)$ pip3 install -U "git+https://github.com/pretix/pretix.git#egg=pretix"
(venv)$ python -m pretix migrate (venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild (venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles (venv)$ python -m pretix updateassets
# systemctl restart pretix-web pretix-worker # systemctl restart pretix-web pretix-worker
Docker installation Docker installation
+2 -2
View File
@@ -285,7 +285,7 @@ To upgrade to a new pretix release, pull the latest code changes and run the fol
(venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn (venv)$ pip3 install -U --upgrade-strategy eager pretix gunicorn
(venv)$ python -m pretix migrate (venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild (venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles (venv)$ python -m pretix updateassets
# systemctl restart pretix-web pretix-worker # systemctl restart pretix-web pretix-worker
Make sure to also read :ref:`update_notes` and the release notes of the version you are updating to. Pay special Make sure to also read :ref:`update_notes` and the release notes of the version you are updating to. Pay special
@@ -325,7 +325,7 @@ Then, proceed like after any plugin installation::
(venv)$ python -m pretix migrate (venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild (venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles (venv)$ python -m pretix updateassets
# systemctl restart pretix-web pretix-worker # systemctl restart pretix-web pretix-worker
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-22-04 .. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-22-04
+16 -2
View File
@@ -20,8 +20,12 @@ id integer Internal ID
active boolean The discount will be ignored if this is ``false`` active boolean The discount will be ignored if this is ``false``
internal_name string A name for the rule used in the backend internal_name string A name for the rule used in the backend
position integer An integer, used for sorting the rules which are applied in order position integer An integer, used for sorting the rules which are applied in order
sales_channels list of strings Sales channels this discount is available on, such as all_sales_channels boolean If ``true`` (default), the discount is available on all sales channels
``"web"`` or ``"resellers"``. Defaults to ``["web"]``. that support discounts.
limit_sales_channels list of strings List of sales channel identifiers the discount is available on
if ``all_sales_channels`` is ``false``.
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
available_from datetime The first date time at which this discount can be applied available_from datetime The first date time at which this discount can be applied
(or ``null``). (or ``null``).
available_until datetime The last date time at which this discount can be applied available_until datetime The last date time at which this discount can be applied
@@ -95,6 +99,8 @@ Endpoints
"active": true, "active": true,
"internal_name": "3 for 2", "internal_name": "3 for 2",
"position": 1, "position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
@@ -151,6 +157,8 @@ Endpoints
"active": true, "active": true,
"internal_name": "3 for 2", "internal_name": "3 for 2",
"position": 1, "position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
@@ -193,6 +201,8 @@ Endpoints
"active": true, "active": true,
"internal_name": "3 for 2", "internal_name": "3 for 2",
"position": 1, "position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
@@ -224,6 +234,8 @@ Endpoints
"active": true, "active": true,
"internal_name": "3 for 2", "internal_name": "3 for 2",
"position": 1, "position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
@@ -284,6 +296,8 @@ Endpoints
"active": false, "active": false,
"internal_name": "3 for 2", "internal_name": "3 for 2",
"position": 1, "position": 1,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
+20 -13
View File
@@ -49,8 +49,11 @@ item_meta_properties object Item-specific m
valid_keys object Cryptographic keys for non-default signature schemes. valid_keys object Cryptographic keys for non-default signature schemes.
For performance reason, value is omitted in lists and For performance reason, value is omitted in lists and
only contained in detail views. Value can be cached. only contained in detail views. Value can be cached.
sales_channels list A list of sales channels this event is available for all_sales_channels boolean If ``true`` (default), the event is available on all sales channels.
sale on. limit_sales_channels list of strings List of sales channel identifiers the event is available on
if ``all_sales_channels`` is ``false``.
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
public_url string The public, customer-facing URL of the event (read-only). public_url string The public, customer-facing URL of the event (read-only).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -131,11 +134,13 @@ Endpoints
"pretix.plugins.paypal", "pretix.plugins.paypal",
"pretix.plugins.ticketoutputpdf" "pretix.plugins.ticketoutputpdf"
], ],
"sales_channels": [ "all_sales_channels": false,
"limit_sales_channels": [
"web", "web",
"pretixpos", "pretixpos",
"resellers" "resellers"
], ],
"sales_channels": [],
"public_url": "https://pretix.eu/bigevents/sampleconf/" "public_url": "https://pretix.eu/bigevents/sampleconf/"
} }
] ]
@@ -225,6 +230,8 @@ Endpoints
"LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo=" "LS0tLS1CRUdJTiBQVUJMSUMgS0VZLS0tLS0KTUNvd0JRWURLMlZ3QXlFQTdBRDcvdkZBMzNFc1k0ejJQSHI3aVpQc1o4bjVkaDBhalA4Z3l6Tm1tSXM9Ci0tLS0tRU5EIFBVQkxJQyBLRVktLS0tLQo="
] ]
}, },
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [ "sales_channels": [
"web", "web",
"pretixpos", "pretixpos",
@@ -282,11 +289,8 @@ Endpoints
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
], ],
"sales_channels": [ "all_sales_channels": true,
"web", "limit_sales_channels": []
"pretixpos",
"resellers"
]
} }
**Example response**: **Example response**:
@@ -322,6 +326,8 @@ Endpoints
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
], ],
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [ "sales_channels": [
"web", "web",
"pretixpos", "pretixpos",
@@ -387,11 +393,8 @@ Endpoints
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
], ],
"sales_channels": [ "all_sales_channels": true,
"web", "limit_sales_channels": []
"pretixpos",
"resellers"
]
} }
**Example response**: **Example response**:
@@ -427,6 +430,8 @@ Endpoints
"pretix.plugins.stripe", "pretix.plugins.stripe",
"pretix.plugins.paypal" "pretix.plugins.paypal"
], ],
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [ "sales_channels": [
"web", "web",
"pretixpos", "pretixpos",
@@ -502,6 +507,8 @@ Endpoints
"pretix.plugins.paypal", "pretix.plugins.paypal",
"pretix.plugins.pretixdroid" "pretix.plugins.pretixdroid"
], ],
"all_sales_channels": true,
"limit_sales_channels": [],
"sales_channels": [ "sales_channels": [
"web", "web",
"pretixpos", "pretixpos",
+1
View File
@@ -30,6 +30,7 @@ at :ref:`plugin-docs`.
checkinlists checkinlists
waitinglist waitinglist
customers customers
saleschannels
membershiptypes membershiptypes
memberships memberships
giftcards giftcards
+19 -5
View File
@@ -38,11 +38,14 @@ require_membership boolean If ``true``, bo
require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will require_membership_hidden boolean If ``true`` and ``require_membership`` is set, this variation will
be hidden from users without a valid membership. be hidden from users without a valid membership.
require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
sales_channels list of strings Sales channels this variation is available on, such as all_sales_channels boolean If ``true`` (default), the variation is available on all sales channels.
``"web"`` or ``"resellers"``. Defaults to all existing sales channels. limit_sales_channels list of strings List of sales channel identifiers the variation is available on
if ``all_sales_channels`` is ``false``.
The item-level list takes precedence, i.e. a sales The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the item to be channel needs to be on both lists for the variation to be
available. available (unless ``all_sales_channels`` is used).
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
available_from datetime The first date time at which this variation can be bought available_from datetime The first date time at which this variation can be bought
(or ``null``). (or ``null``).
available_from_mode string If ``hide`` (the default), this variation is hidden in the shop available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
@@ -111,6 +114,8 @@ Endpoints
"require_membership": false, "require_membership": false,
"require_membership_hidden": false, "require_membership_hidden": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -139,6 +144,8 @@ Endpoints
"require_membership": false, "require_membership": false,
"require_membership_hidden": false, "require_membership_hidden": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -202,6 +209,8 @@ Endpoints
"require_membership": false, "require_membership": false,
"require_membership_hidden": false, "require_membership_hidden": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -244,7 +253,8 @@ Endpoints
"require_membership": false, "require_membership": false,
"require_membership_hidden": false, "require_membership_hidden": false,
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "all_sales_channels": false,
"limit_sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
"available_until": null, "available_until": null,
@@ -277,6 +287,8 @@ Endpoints
"require_membership": false, "require_membership": false,
"require_membership_hidden": false, "require_membership_hidden": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -341,6 +353,8 @@ Endpoints
"require_membership": false, "require_membership": false,
"require_membership_hidden": false, "require_membership_hidden": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
+42 -9
View File
@@ -46,8 +46,11 @@ personalized boolean ``true`` for
position integer An integer, used for sorting position integer An integer, used for sorting
picture file A product picture to be displayed in the shop picture file A product picture to be displayed in the shop
(can be ``null``). (can be ``null``).
sales_channels list of strings Sales channels this product is available on, such as all_sales_channels boolean If ``true`` (default), the item is available on all sales channels.
``"web"`` or ``"resellers"``. Defaults to ``["web"]``. limit_sales_channels list of strings List of sales channel identifiers the item is available on
if ``all_sales_channels`` is ``false``.
sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
available_from datetime The first date time at which this item can be bought available_from datetime The first date time at which this item can be bought
(or ``null``). (or ``null``).
available_from_mode string If ``hide`` (the default), this item is hidden in the shop available_from_mode string If ``hide`` (the default), this item is hidden in the shop
@@ -157,11 +160,14 @@ variations list of objects A list with o
be hidden from users without a valid membership. be hidden from users without a valid membership.
├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true`` ├ require_membership_types list of integers Internal IDs of membership types valid if ``require_membership`` is ``true``
Markdown syntax or can be ``null``. Markdown syntax or can be ``null``.
├ sales_channels list of strings Sales channels this variation is available on, such as all_sales_channels boolean If ``true`` (default), the variation is available on all sales channels.
``"web"`` or ``"resellers"``. Defaults to all existing sales channels. ├ limit_sales_channels list of strings List of sales channel identifiers the variation is available on
if ``all_sales_channels`` is ``false``.
The item-level list takes precedence, i.e. a sales The item-level list takes precedence, i.e. a sales
channel needs to be on both lists for the item to be channel needs to be on both lists for the variation to be
available. available (unless ``all_sales_channels`` is used).
├ sales_channels list of strings **DEPRECATED.** Legacy interface, use ``all_sales_channels``
and ``limit_sales_channels`` instead.
├ available_from datetime The first date time at which this variation can be bought ├ available_from datetime The first date time at which this variation can be bought
(or ``null``). (or ``null``).
├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop ├ available_from_mode string If ``hide`` (the default), this variation is hidden in the shop
@@ -276,6 +282,8 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "Standard ticket"}, "name": {"en": "Standard ticket"},
"internal_name": "", "internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"default_price": "23.00", "default_price": "23.00",
"original_price": null, "original_price": null,
@@ -340,6 +348,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -362,6 +372,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -420,6 +432,8 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "Standard ticket"}, "name": {"en": "Standard ticket"},
"internal_name": "", "internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"default_price": "23.00", "default_price": "23.00",
"original_price": null, "original_price": null,
@@ -485,6 +499,8 @@ Endpoints
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"description": null, "description": null,
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -506,6 +522,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -545,7 +563,8 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "Standard ticket"}, "name": {"en": "Standard ticket"},
"internal_name": "", "internal_name": "",
"sales_channels": ["web"], "all_sales_channels": false,
"limit_sales_channels": ["web"],
"default_price": "23.00", "default_price": "23.00",
"original_price": null, "original_price": null,
"category": null, "category": null,
@@ -608,7 +627,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "all_sales_channels": false,
"limit_sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
"available_until": null, "available_until": null,
@@ -630,7 +650,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"sales_channels": ["web"], "all_sales_channels": false,
"limit_sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
"available_until": null, "available_until": null,
@@ -657,6 +678,8 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "Standard ticket"}, "name": {"en": "Standard ticket"},
"internal_name": "", "internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"default_price": "23.00", "default_price": "23.00",
"original_price": null, "original_price": null,
@@ -721,6 +744,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -743,6 +768,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -801,6 +828,8 @@ Endpoints
"id": 1, "id": 1,
"name": {"en": "Ticket"}, "name": {"en": "Ticket"},
"internal_name": "", "internal_name": "",
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"default_price": "25.00", "default_price": "25.00",
"original_price": null, "original_price": null,
@@ -865,6 +894,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
@@ -887,6 +918,8 @@ Endpoints
"require_approval": false, "require_approval": false,
"require_membership": false, "require_membership": false,
"require_membership_types": [], "require_membership_types": [],
"all_sales_channels": false,
"limit_sales_channels": ["web"],
"sales_channels": ["web"], "sales_channels": ["web"],
"available_from": null, "available_from": null,
"available_from_mode": "hide", "available_from_mode": "hide",
+219
View File
@@ -0,0 +1,219 @@
Sales channels
==============
Resource description
--------------------
The sales channel resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
identifier string Internal ID of the sales channel. For sales channel types
that allow only one instance, this is the same as ``type``.
For sales channel types that allow multiple instances, this
is always prefixed with ``type.``.
label multi-lingual string Human-readable name of the sales channel
type string Type of the sales channel. Only channels with type ``api``
can currently be created through the API.
position integer Position for sorting lists of sales channels
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/saleschannels/
Returns a list of all sales channels within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/saleschannels/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"identifier": "web",
"name": {
"en": "Online shop"
},
"type": "web",
"position": 0
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/saleschannels/(identifier)/
Returns information on one sales channel, identified by its identifier.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/saleschannels/web/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"identifier": "web",
"name": {
"en": "Online shop"
},
"type": "web",
"position": 0
}
:param organizer: The ``slug`` field of the organizer to fetch
:param identifier: The ``identifier`` field of the sales channel to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/saleschannels/
Creates a sales channel
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/saleschannels/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"identifier": "api.custom",
"name": {
"en": "Custom integration"
},
"type": "api",
"position": 2
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"identifier": "api.custom",
"name": {
"en": "Custom integration"
},
"type": "api",
"position": 2
}
:param organizer: The ``slug`` field of the organizer to create a sales channel for
:statuscode 201: no error
:statuscode 400: The sales channel could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/saleschannels/(identifier)/
Update a sales channel. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``identifier`` and ``type`` fields.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/saleschannels/web/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"position": 5
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"identifier": "web",
"name": {
"en": "Online shop"
},
"type": "web",
"position": 5
}
:param organizer: The ``slug`` field of the organizer to modify
:param identifier: The ``identifier`` field of the sales channel to modify
:statuscode 200: no error
:statuscode 400: The sales channel could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/saleschannels/(identifier)/
Delete a sales channel. You can not delete sales channels which have already been used or which are integral parts
of the system.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/saleschannels/api.custom/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param identifier: The ``identifier`` field of the sales channel to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the sales channel is currently in use.
+2 -2
View File
@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification, :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, notification,
item_copy_data, register_sales_channels, register_global_settings, quota_availability, global_email_filter, item_copy_data, register_sales_channel_types, register_global_settings, quota_availability, global_email_filter,
register_ticket_secret_generators, gift_card_transaction_display, register_ticket_secret_generators, gift_card_transaction_display,
register_text_placeholders, register_mail_placeholders register_text_placeholders, register_mail_placeholders
@@ -35,7 +35,7 @@ Frontend
-------- --------
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header :members: html_head, html_footer, footer_link, global_footer_link, front_page_top, front_page_bottom, front_page_bottom_widget, fee_calculation_for_cart, contact_form_fields, question_form_fields, contact_form_fields_overrides, question_form_fields_overrides, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, render_seating_plan, checkout_flow_steps, position_info, position_info_top, item_description, global_html_head, global_html_footer, global_html_page_header
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
+1 -1
View File
@@ -218,7 +218,7 @@ To update the frontend styles of shops with a custom styling, run the following
your virtual environment.:: your virtual environment.::
python -m pretix collectstatic --noinput python -m pretix collectstatic --noinput
python -m pretix updatestyles python -m pretix updateassets
.. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver .. _Django's documentation: https://docs.djangoproject.com/en/1.11/ref/django-admin/#runserver
+2 -2
View File
@@ -59,7 +59,7 @@ dependencies = [
"dnspython==2.6.*", "dnspython==2.6.*",
"drf_ujson2==1.7.*", "drf_ujson2==1.7.*",
"geoip2==4.*", "geoip2==4.*",
"importlib_metadata==7.*", # Polyfill, we can probably drop this once we require Python 3.10+ "importlib_metadata==8.*", # Polyfill, we can probably drop this once we require Python 3.10+
"isoweek", "isoweek",
"jsonschema", "jsonschema",
"kombu==5.3.*", "kombu==5.3.*",
@@ -103,7 +103,7 @@ dependencies = [
"ua-parser==0.18.*", "ua-parser==0.18.*",
"vat_moss_forked==2020.3.20.0.11.0", "vat_moss_forked==2020.3.20.0.11.0",
"vobject==0.9.*", "vobject==0.9.*",
"webauthn==2.1.*", "webauthn==2.2.*",
"zeep==4.2.*" "zeep==4.2.*"
] ]
+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__ = "2024.6.0.dev0" __version__ = "2024.7.0.dev0"
+1
View File
@@ -100,6 +100,7 @@ ALL_LANGUAGES = [
('ro', _('Romanian')), ('ro', _('Romanian')),
('ru', _('Russian')), ('ru', _('Russian')),
('sk', _('Slovak')), ('sk', _('Slovak')),
('sv', _('Swedish')),
('es', _('Spanish')), ('es', _('Spanish')),
('tr', _('Turkish')), ('tr', _('Turkish')),
('uk', _('Ukrainian')), ('uk', _('Ukrainian')),
+55
View File
@@ -21,6 +21,7 @@
# #
import json import json
from django.core.exceptions import ValidationError
from rest_framework import serializers from rest_framework import serializers
@@ -61,3 +62,57 @@ class CompatibleJSONField(serializers.JSONField):
if value: if value:
return json.loads(value) return json.loads(value)
return value return value
class SalesChannelMigrationMixin:
"""
Translates between the old field "sales_channels" and the new field combo "all_sales_channels"/"limit_sales_channels".
"""
@property
def organizer(self):
if "organizer" in self.context:
return self.context["organizer"]
elif "event" in self.context:
return self.context["event"].organizer
else:
raise ValueError("organizer not in context")
def to_internal_value(self, data):
if "sales_channels" in data:
all_channels = {
s.identifier for s in
self.organizer.sales_channels.all()
}
if data.get("all_sales_channels") and set(data["sales_channels"]) != all_channels:
raise ValidationError(
"If 'all_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the list of all sales channels."
)
if data.get("limit_sales_channels") and set(data["sales_channels"]) != set(data["limit_sales_channels"]):
raise ValidationError(
"If 'limit_sales_channels' is set, the legacy attribute 'sales_channels' must not be set or set to "
"the same list."
)
if data["sales_channels"] == all_channels:
data["all_sales_channels"] = True
data["limit_sales_channels"] = []
else:
data["all_sales_channels"] = False
data["limit_sales_channels"] = data["sales_channels"]
del data["sales_channels"]
return super().to_internal_value(data)
def to_representation(self, value):
value = super().to_representation(value)
if value.get("all_sales_channels"):
value["sales_channels"] = sorted([
s.identifier for s in
self.organizer.sales_channels.all()
])
else:
value["sales_channels"] = value["limit_sales_channels"]
return value
+10 -2
View File
@@ -33,7 +33,7 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer, AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
) )
from pretix.base.models import Seat, Voucher from pretix.base.models import SalesChannel, Seat, Voucher
from pretix.base.models.orders import CartPosition from pretix.base.models.orders import CartPosition
@@ -212,7 +212,11 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
addons = BaseCartPositionCreateSerializer(many=True, required=False) addons = BaseCartPositionCreateSerializer(many=True, required=False)
bundled = BaseCartPositionCreateSerializer(many=True, required=False) bundled = BaseCartPositionCreateSerializer(many=True, required=False)
seat = serializers.CharField(required=False, allow_null=True) seat = serializers.CharField(required=False, allow_null=True)
sales_channel = serializers.CharField(required=False, default='sales_channel') sales_channel = serializers.SlugRelatedField(
slug_field='identifier',
queryset=SalesChannel.objects.none(),
required=False,
)
voucher = serializers.CharField(required=False, allow_null=True) voucher = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
@@ -221,6 +225,10 @@ class CartPositionCreateSerializer(BaseCartPositionCreateSerializer):
'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher' 'cart_id', 'expires', 'addons', 'bundled', 'seat', 'sales_channel', 'voucher'
) )
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
def validate_cart_id(self, cid): def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'): if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.') raise ValidationError('Cart ID should end in @api or be empty.')
+10 -6
View File
@@ -25,14 +25,20 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer from pretix.api.serializers.event import SubEventSerializer
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import Checkin, CheckinList from pretix.base.models import Checkin, CheckinList, SalesChannel
class CheckinListSerializer(I18nAwareModelSerializer): class CheckinListSerializer(I18nAwareModelSerializer):
checkin_count = serializers.IntegerField(read_only=True) checkin_count = serializers.IntegerField(read_only=True)
position_count = serializers.IntegerField(read_only=True) position_count = serializers.IntegerField(read_only=True)
auto_checkin_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta: class Meta:
model = CheckinList model = CheckinList
@@ -43,6 +49,8 @@ class CheckinListSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['auto_checkin_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
if 'subevent' in self.context['request'].query_params.getlist('expand'): if 'subevent' in self.context['request'].query_params.getlist('expand'):
self.fields['subevent'] = SubEventSerializer(read_only=True) self.fields['subevent'] = SubEventSerializer(read_only=True)
@@ -72,10 +80,6 @@ class CheckinListSerializer(I18nAwareModelSerializer):
if full_data.get('subevent'): if full_data.get('subevent'):
raise ValidationError(_('The subevent does not belong to this event.')) raise ValidationError(_('The subevent does not belong to this event.'))
for channel in full_data.get('auto_checkin_sales_channels') or []:
if channel not in get_all_sales_channels():
raise ValidationError(_('Unknown sales channel.'))
CheckinList.validate_rules(data.get('rules')) CheckinList.validate_rules(data.get('rules'))
return data return data
+15 -5
View File
@@ -19,18 +19,27 @@
# 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/>.
# #
from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Discount from pretix.base.models import Discount, SalesChannel
class DiscountSerializer(I18nAwareModelSerializer): class DiscountSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta: class Meta:
model = Discount model = Discount
fields = ('id', 'active', 'internal_name', 'position', 'sales_channels', 'available_from', fields = ('id', 'active', 'internal_name', 'position', 'all_sales_channels', 'limit_sales_channels',
'available_until', 'subevent_mode', 'condition_all_products', 'condition_limit_products', 'available_from', 'available_until', 'subevent_mode', 'condition_all_products',
'condition_apply_to_addons', 'condition_min_count', 'condition_min_value', 'condition_limit_products', 'condition_apply_to_addons', 'condition_min_count', 'condition_min_value',
'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches', 'benefit_discount_matching_percent', 'benefit_only_apply_to_cheapest_n_matches',
'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons', 'benefit_same_products', 'benefit_limit_products', 'benefit_apply_to_addons',
'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted') 'benefit_ignore_voucher_discounted', 'condition_ignore_voucher_discounted')
@@ -39,6 +48,7 @@ class DiscountSerializer(I18nAwareModelSerializer):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['condition_limit_products'].queryset = self.context['event'].items.all() self.fields['condition_limit_products'].queryset = self.context['event'].items.all()
self.fields['benefit_limit_products'].queryset = self.context['event'].items.all() self.fields['benefit_limit_products'].queryset = self.context['event'].items.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
+16 -4
View File
@@ -46,10 +46,14 @@ from rest_framework import serializers
from rest_framework.fields import ChoiceField, Field from rest_framework.fields import ChoiceField, Field
from rest_framework.relations import SlugRelatedField from rest_framework.relations import SlugRelatedField
from pretix.api.serializers import CompatibleJSONField from pretix.api.serializers import (
CompatibleJSONField, SalesChannelMigrationMixin,
)
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.settings import SettingsSerializer from pretix.api.serializers.settings import SettingsSerializer
from pretix.base.models import Device, Event, TaxRule, TeamAPIToken from pretix.base.models import (
Device, Event, SalesChannel, TaxRule, TeamAPIToken,
)
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import ( from pretix.base.models.items import (
ItemMetaProperty, SubEventItem, SubEventItemVariation, ItemMetaProperty, SubEventItem, SubEventItemVariation,
@@ -161,7 +165,7 @@ class ValidKeysField(Field):
} }
class EventSerializer(I18nAwareModelSerializer): class EventSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
item_meta_properties = MetaPropertyField(required=False, source='*') item_meta_properties = MetaPropertyField(required=False, source='*')
plugins = PluginsField(required=False, source='*') plugins = PluginsField(required=False, source='*')
@@ -170,6 +174,13 @@ class EventSerializer(I18nAwareModelSerializer):
valid_keys = ValidKeysField(source='*', read_only=True) valid_keys = ValidKeysField(source='*', read_only=True)
best_availability_state = serializers.IntegerField(allow_null=True, read_only=True) best_availability_state = serializers.IntegerField(allow_null=True, read_only=True)
public_url = serializers.SerializerMethodField('get_event_url', read_only=True) public_url = serializers.SerializerMethodField('get_event_url', read_only=True)
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
def get_event_url(self, event): def get_event_url(self, event):
return build_absolute_uri(event, 'presale:event.index') return build_absolute_uri(event, 'presale:event.index')
@@ -180,7 +191,7 @@ class EventSerializer(I18nAwareModelSerializer):
'date_to', 'date_admission', 'is_public', 'presale_start', 'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan', 'presale_end', 'location', 'geo_lat', 'geo_lon', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys', 'plugins', 'seat_category_mapping', 'timezone', 'item_meta_properties', 'valid_keys',
'sales_channels', 'best_availability_state', 'public_url') 'all_sales_channels', 'limit_sales_channels', 'best_availability_state', 'public_url')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -188,6 +199,7 @@ class EventSerializer(I18nAwareModelSerializer):
self.fields.pop('valid_keys') self.fields.pop('valid_keys')
if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET: if not self.context.get('request') or 'with_availability_for' not in self.context['request'].GET:
self.fields.pop('best_availability_state') self.fields.pop('best_availability_state')
self.fields['limit_sales_channels'].child_relation.queryset = self.context['organizer'].sales_channels.all()
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
+36 -8
View File
@@ -42,19 +42,27 @@ from django.utils.functional import cached_property, lazy
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from rest_framework import serializers from rest_framework import serializers
from pretix.api.serializers import SalesChannelMigrationMixin
from pretix.api.serializers.event import MetaDataField from pretix.api.serializers.event import MetaDataField
from pretix.api.serializers.fields import UploadedFileField from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import ( from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Question, QuestionOption, Quota, ItemVariationMetaValue, Question, QuestionOption, Quota, SalesChannel,
) )
class InlineItemVariationSerializer(I18nAwareModelSerializer): class InlineItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13, price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True) coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta: class Meta:
model = ItemVariation model = ItemVariation
@@ -63,11 +71,12 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
'require_membership', 'require_membership_types', 'require_membership_hidden', 'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text', 'checkin_attention', 'checkin_text',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'sales_channels', 'hide_without_voucher', 'meta_data') 'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet) self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
self.fields['limit_sales_channels'].child_relation.queryset = lazy(lambda: self.context['event'].organizer.sales_channels.all(), QuerySet)
def validate_meta_data(self, value): def validate_meta_data(self, value):
for key in value['meta_data'].keys(): for key in value['meta_data'].keys():
@@ -76,10 +85,17 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
return value return value
class ItemVariationSerializer(I18nAwareModelSerializer): class ItemVariationSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13, price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True) coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta: class Meta:
model = ItemVariation model = ItemVariation
@@ -88,11 +104,12 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
'require_membership', 'require_membership_types', 'require_membership_hidden', 'require_membership', 'require_membership_types', 'require_membership_hidden',
'checkin_attention', 'checkin_text', 'checkin_attention', 'checkin_text',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'sales_channels', 'hide_without_voucher', 'meta_data') 'all_sales_channels', 'limit_sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
@transaction.atomic @transaction.atomic
def create(self, validated_data): def create(self, validated_data):
@@ -223,7 +240,7 @@ class ItemTaxRateField(serializers.Field):
return str(Decimal('0.00')) return str(Decimal('0.00'))
class ItemSerializer(I18nAwareModelSerializer): class ItemSerializer(SalesChannelMigrationMixin, I18nAwareModelSerializer):
addons = InlineItemAddOnSerializer(many=True, required=False) addons = InlineItemAddOnSerializer(many=True, required=False)
bundles = InlineItemBundleSerializer(many=True, required=False) bundles = InlineItemBundleSerializer(many=True, required=False)
variations = InlineItemVariationSerializer(many=True, required=False) variations = InlineItemVariationSerializer(many=True, required=False)
@@ -232,11 +249,18 @@ class ItemSerializer(I18nAwareModelSerializer):
picture = UploadedFileField(required=False, allow_null=True, allowed_types=( picture = UploadedFileField(required=False, allow_null=True, allowed_types=(
'image/png', 'image/jpeg', 'image/gif' 'image/png', 'image/jpeg', 'image/gif'
), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE) ), max_size=settings.FILE_UPLOAD_MAX_SIZE_IMAGE)
limit_sales_channels = serializers.SlugRelatedField(
slug_field="identifier",
queryset=SalesChannel.objects.none(),
required=False,
allow_empty=True,
many=True,
)
class Meta: class Meta:
model = Item model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', fields = ('id', 'category', 'name', 'internal_name', 'active', 'all_sales_channels', 'limit_sales_channels',
'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission', 'description', 'default_price', 'free_price', 'free_price_suggestion', 'tax_rate', 'tax_rule', 'admission',
'personalized', 'position', 'picture', 'personalized', 'position', 'picture',
'available_from', 'available_from_mode', 'available_until', 'available_until_mode', 'available_from', 'available_from_mode', 'available_until', 'available_until_mode',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
@@ -259,6 +283,7 @@ class ItemSerializer(I18nAwareModelSerializer):
if not self.read_only: if not self.read_only:
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all() self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all() self.fields['grant_membership_type'].queryset = self.context['event'].organizer.membership_types.all()
self.fields['limit_sales_channels'].child_relation.queryset = self.context['event'].organizer.sales_channels.all()
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -335,7 +360,10 @@ class ItemSerializer(I18nAwareModelSerializer):
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
picture = validated_data.pop('picture', None) picture = validated_data.pop('picture', None)
require_membership_types = validated_data.pop('require_membership_types', []) require_membership_types = validated_data.pop('require_membership_types', [])
limit_sales_channels = validated_data.pop('limit_sales_channels', [])
item = Item.objects.create(**validated_data) item = Item.objects.create(**validated_data)
if limit_sales_channels:
item.limit_sales_channels.add(*limit_sales_channels)
if picture: if picture:
item.picture.save(os.path.basename(picture.name), picture) item.picture.save(os.path.basename(picture.name), picture)
if require_membership_types: if require_membership_types:
+27 -23
View File
@@ -46,13 +46,12 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.item import ( from pretix.api.serializers.item import (
InlineItemVariationSerializer, ItemSerializer, QuestionSerializer, InlineItemVariationSerializer, ItemSerializer, QuestionSerializer,
) )
from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item, CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
ReusableMedium, Seat, SubEvent, TaxRule, Voucher, ReusableMedium, SalesChannel, Seat, SubEvent, TaxRule, Voucher,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund, BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -714,6 +713,11 @@ class OrderSerializer(I18nAwareModelSerializer):
payment_provider = OrderPaymentTypeField(source='*', read_only=True) payment_provider = OrderPaymentTypeField(source='*', read_only=True)
url = OrderURLField(source='*', read_only=True) url = OrderURLField(source='*', read_only=True)
customer = serializers.SlugRelatedField(slug_field='identifier', read_only=True) customer = serializers.SlugRelatedField(slug_field='identifier', read_only=True)
sales_channel = serializers.SlugRelatedField(
slug_field='identifier',
queryset=SalesChannel.objects.none(),
required=False,
)
class Meta: class Meta:
model = Order model = Order
@@ -732,6 +736,10 @@ class OrderSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if "organizer" in self.context:
self.fields["sales_channel"].queryset = self.context["organizer"].sales_channels.all()
else:
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
if not self.context['pdf_data']: if not self.context['pdf_data']:
self.fields['positions'].child.fields.pop('pdf_data', None) self.fields['positions'].child.fields.pop('pdf_data', None)
@@ -1033,12 +1041,18 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
require_approval = serializers.BooleanField(default=False, required=False) require_approval = serializers.BooleanField(default=False, required=False)
simulate = serializers.BooleanField(default=False, required=False) simulate = serializers.BooleanField(default=False, required=False)
customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False) customer = serializers.SlugRelatedField(slug_field='identifier', queryset=Customer.objects.none(), required=False)
sales_channel = serializers.SlugRelatedField(
slug_field='identifier',
queryset=SalesChannel.objects.none(),
required=False,
)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all() self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
self.fields['customer'].queryset = self.context['event'].organizer.customers.all() self.fields['customer'].queryset = self.context['event'].organizer.customers.all()
self.fields['expires'].required = False self.fields['expires'].required = False
self.fields["sales_channel"].queryset = self.context["event"].organizer.sales_channels.all()
class Meta: class Meta:
model = Order model = Order
@@ -1059,11 +1073,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError('Expiration date must be in the future.') raise ValidationError('Expiration date must be in the future.')
return expires return expires
def validate_sales_channel(self, channel):
if channel not in get_all_sales_channels():
raise ValidationError('Unknown sales channel.')
return channel
def validate_code(self, code): def validate_code(self, code):
if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists(): if code and Order.objects.filter(event__organizer=self.context['event'].organizer, code=code).exists():
raise ValidationError( raise ValidationError(
@@ -1125,20 +1134,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(errs) raise ValidationError(errs)
return data return data
def validate_testmode(self, testmode):
if 'sales_channel' in self.initial_data:
try:
sales_channel = get_all_sales_channels()[self.initial_data['sales_channel']]
if testmode and not sales_channel.testmode_supported:
raise ValidationError('This sales channel does not provide support for test mode.')
except KeyError:
# We do not need to raise a ValidationError here, since there is another check to validate the
# sales_channel
pass
return testmode
def create(self, validated_data): def create(self, validated_data):
fees_data = validated_data.pop('fees') if 'fees' in validated_data else [] fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
positions_data = validated_data.pop('positions') if 'positions' in validated_data else [] positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
@@ -1147,9 +1142,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
payment_date = validated_data.pop('payment_date', now()) payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False) force = validated_data.pop('force', False)
simulate = validated_data.pop('simulate', False) simulate = validated_data.pop('simulate', False)
if not validated_data.get("sales_channel"):
validated_data["sales_channel"] = self.context['event'].organizer.sales_channels.get(identifier="web")
if validated_data.get("testmode") and not validated_data["sales_channel"].type_instance.testmode_supported:
raise ValidationError({"testmode": ["This sales channel does not provide support for test mode."]})
self._send_mail = validated_data.pop('send_email', False) self._send_mail = validated_data.pop('send_email', False)
if self._send_mail is None: if self._send_mail is None:
self._send_mail = validated_data.get('sales_channel') in self.context['event'].settings.mail_sales_channel_placed_paid self._send_mail = validated_data["sales_channel"].identifier in self.context['event'].settings.mail_sales_channel_placed_paid
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -1309,7 +1311,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
errs[i]['seat'] = ['The specified seat does not exist.'] errs[i]['seat'] = ['The specified seat does not exist.']
else: else:
seat_usage[seat] += 1 seat_usage[seat] += 1
if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=validated_data.get('sales_channel', 'web'))) or seat_usage[seat] > 1: sales_channel_id = validated_data['sales_channel'].identifier
if (seat_usage[seat] > 0 and not seat.is_available(sales_channel=sales_channel_id)) or seat_usage[seat] > 1:
errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)] errs[i]['seat'] = [gettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
elif seated: elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.'] errs[i]['seat'] = ['The specified product requires to choose a seat.']
@@ -1368,6 +1371,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if validated_data.get('locale', None) is None: if validated_data.get('locale', None) is None:
validated_data['locale'] = self.context['event'].settings.locale validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data) order = Order(event=self.context['event'], **validated_data)
if not validated_data.get('expires'): if not validated_data.get('expires'):
order.set_expires(subevents=[p.get('subevent') for p in positions_data]) order.set_expires(subevents=[p.get('subevent') for p in positions_data])
+31 -1
View File
@@ -38,7 +38,7 @@ from pretix.base.i18n import get_language_without_region
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction, Customer, Device, GiftCard, GiftCardAcceptance, GiftCardTransaction,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SeatingPlan, Team, TeamAPIToken, TeamInvite, User, SalesChannel, SeatingPlan, Team, TeamAPIToken, TeamInvite, User,
) )
from pretix.base.models.seating import SeatingPlanLayoutValidator from pretix.base.models.seating import SeatingPlanLayoutValidator
from pretix.base.services.mail import SendMailException, mail from pretix.base.services.mail import SendMailException, mail
@@ -165,6 +165,36 @@ class FlexibleTicketRelatedField(serializers.PrimaryKeyRelatedField):
self.fail('incorrect_type', data_type=type(data).__name__) self.fail('incorrect_type', data_type=type(data).__name__)
class SalesChannelSerializer(I18nAwareModelSerializer):
type = serializers.CharField(default="api")
class Meta:
model = SalesChannel
fields = ('identifier', 'type', 'label', 'position')
def validate_type(self, value):
if (not self.instance or not self.instance.pk) and value != "api":
raise ValidationError(
"You can currently only create channels of type 'api' through the API."
)
if value and self.instance and self.instance.pk and self.instance.type != value:
raise ValidationError(
"You cannot change the type of a sales channel."
)
return value
def validate_identifier(self, value):
if (not self.instance or not self.instance.pk) and not value.startswith("api."):
raise ValidationError(
"Your identifier needs to start with 'api.'."
)
if value and self.instance and self.instance.pk and self.instance.identifier != value:
raise ValidationError(
"You cannot change the identifier of a sales channel."
)
return value
class GiftCardSerializer(I18nAwareModelSerializer): class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00')) value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
owner_ticket = FlexibleTicketRelatedField(required=False, allow_null=True, queryset=OrderPosition.all.none()) owner_ticket = FlexibleTicketRelatedField(required=False, allow_null=True, queryset=OrderPosition.all.none())
+1
View File
@@ -56,6 +56,7 @@ orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet) orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
orga_router.register(r'giftcards', organizer.GiftCardViewSet) orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'customers', organizer.CustomerViewSet) orga_router.register(r'customers', organizer.CustomerViewSet)
orga_router.register(r'saleschannels', organizer.SalesChannelViewSet)
orga_router.register(r'memberships', organizer.MembershipViewSet) orga_router.register(r'memberships', organizer.MembershipViewSet)
orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet) orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet) orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
+5 -1
View File
@@ -211,8 +211,12 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
if validated_data.get('seat'): if validated_data.get('seat'):
# Assumption: Add-ons currently can't have seats, thus we only need to check the main product # Assumption: Add-ons currently can't have seats, thus we only need to check the main product
if validated_data.get('sales_channel'):
sales_channel_id = validated_data.get('sales_channel').identifier
else:
sales_channel_id = "web"
if not validated_data['seat'].is_available( if not validated_data['seat'].is_available(
sales_channel=validated_data.get('sales_channel', 'web'), sales_channel=sales_channel_id,
distance_ignore_cart_id=validated_data['cart_id'], distance_ignore_cart_id=validated_data['cart_id'],
ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None, ignore_voucher_id=validated_data['voucher'].pk if validated_data.get('voucher') else None,
): ):
+1 -1
View File
@@ -115,7 +115,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
if 'subevent' in self.request.query_params.getlist('expand'): if 'subevent' in self.request.query_params.getlist('expand'):
qs = qs.prefetch_related( qs = qs.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', 'auto_checkin_sales_channels'
) )
return qs return qs
+3 -1
View File
@@ -60,7 +60,9 @@ class DiscountViewSet(ConditionalListView, viewsets.ModelViewSet):
write_permission = 'can_change_items' write_permission = 'can_change_items'
def get_queryset(self): def get_queryset(self):
return self.request.event.discounts.all() return self.request.event.discounts.prefetch_related(
'limit_sales_channels',
)
def perform_create(self, serializer): def perform_create(self, serializer):
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
+19 -8
View File
@@ -57,10 +57,8 @@ from pretix.base.models import (
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.services.quotas import QuotaAvailability from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
from pretix.helpers.i18n import i18ncomp from pretix.helpers.i18n import i18ncomp
from pretix.presale.style import regenerate_css
from pretix.presale.views.organizer import filter_qs_by_attr from pretix.presale.views.organizer import filter_qs_by_attr
with scopes_disabled(): with scopes_disabled():
@@ -115,7 +113,10 @@ with scopes_disabled():
return queryset.exclude(expr) return queryset.exclude(expr)
def sales_channel_qs(self, queryset, name, value): def sales_channel_qs(self, queryset, name, value):
return queryset.filter(sales_channels__contains=value) return queryset.filter(
Q(all_sales_channels=True) |
Q(limit_sales_channels__identifier=value)
)
def search_qs(self, queryset, name, value): def search_qs(self, queryset, name, value):
return queryset.filter( return queryset.filter(
@@ -137,6 +138,12 @@ class EventViewSet(viewsets.ModelViewSet):
ordering_fields = ('date_from', 'slug') ordering_fields = ('date_from', 'slug')
filterset_class = EventFilter filterset_class = EventFilter
def get_serializer_context(self):
return {
**super().get_serializer_context(),
"organizer": self.request.organizer,
}
def get_copy_from_queryset(self): def get_copy_from_queryset(self):
if isinstance(self.request.auth, (TeamAPIToken, Device)): if isinstance(self.request.auth, (TeamAPIToken, Device)):
return self.request.auth.get_events_with_any_permission() return self.request.auth.get_events_with_any_permission()
@@ -162,6 +169,7 @@ class EventViewSet(viewsets.ModelViewSet):
'meta_values', 'meta_values',
'meta_values__property', 'meta_values__property',
'item_meta_properties', 'item_meta_properties',
'limit_sales_channels',
Prefetch( Prefetch(
'seat_category_mappings', 'seat_category_mappings',
to_attr='_seat_category_mappings', to_attr='_seat_category_mappings',
@@ -270,8 +278,6 @@ class EventViewSet(viewsets.ModelViewSet):
new_event.is_public = serializer.validated_data['is_public'] new_event.is_public = serializer.validated_data['is_public']
if 'testmode' in serializer.validated_data: if 'testmode' in serializer.validated_data:
new_event.testmode = serializer.validated_data['testmode'] new_event.testmode = serializer.validated_data['testmode']
if 'sales_channels' in serializer.validated_data:
new_event.sales_channels = serializer.validated_data['sales_channels']
if 'has_subevents' in serializer.validated_data: if 'has_subevents' in serializer.validated_data:
new_event.has_subevents = serializer.validated_data['has_subevents'] new_event.has_subevents = serializer.validated_data['has_subevents']
if 'date_admission' in serializer.validated_data: if 'date_admission' in serializer.validated_data:
@@ -279,6 +285,10 @@ class EventViewSet(viewsets.ModelViewSet):
new_event.save() new_event.save()
if 'timezone' in serializer.validated_data: if 'timezone' in serializer.validated_data:
new_event.settings.timezone = serializer.validated_data['timezone'] new_event.settings.timezone = serializer.validated_data['timezone']
if 'all_sales_channels' in serializer.validated_data and 'sales_channels' in serializer.validated_data:
new_event.all_sales_channels = serializer.validated_data['all_sales_channels']
new_event.limit_sales_channels.set(serializer.validated_data['limit_sales_channels'])
else: else:
serializer.instance.set_defaults() serializer.instance.set_defaults()
@@ -381,7 +391,10 @@ with scopes_disabled():
return queryset.exclude(expr) return queryset.exclude(expr)
def sales_channel_qs(self, queryset, name, value): def sales_channel_qs(self, queryset, name, value):
return queryset.filter(event__sales_channels__contains=value) return queryset.filter(
Q(event__all_sales_channels=True) |
Q(event__limit_sales_channels__identifier=value)
)
def search_qs(self, queryset, name, value): def search_qs(self, queryset, name, value):
return queryset.filter( return queryset.filter(
@@ -636,8 +649,6 @@ class EventSettingsView(views.APIView):
k: v for k, v in s.validated_data.items() k: v for k, v in s.validated_data.items()
} }
) )
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
regenerate_css.apply_async(args=(request.event.pk,))
s = EventSettingsSerializer( s = EventSettingsSerializer(
instance=request.event.settings, event=request.event, context={ instance=request.event.settings, event=request.event, context={
'request': request 'request': request
+3 -1
View File
@@ -87,6 +87,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',
).all() ).all()
def perform_create(self, serializer): def perform_create(self, serializer):
@@ -152,7 +153,8 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
return self.item.variations.all().prefetch_related( return self.item.variations.all().prefetch_related(
'meta_values', 'meta_values',
'meta_values__property', 'meta_values__property',
'require_membership_types' 'require_membership_types',
'limit_sales_channels',
) )
def get_serializer_context(self): def get_serializer_context(self):
+6 -1
View File
@@ -229,7 +229,7 @@ class OrderViewSetMixin:
if 'customer' not in self.request.GET.getlist('exclude'): if 'customer' not in self.request.GET.getlist('exclude'):
qs = qs.select_related('customer') qs = qs.select_related('customer')
qs = qs.prefetch_related(self._positions_prefetch(self.request)) qs = qs.select_related('sales_channel').prefetch_related(self._positions_prefetch(self.request))
return qs return qs
def _positions_prefetch(self, request): def _positions_prefetch(self, request):
@@ -316,6 +316,11 @@ class OrganizerOrderViewSet(OrderViewSetMixin, viewsets.ReadOnlyModelViewSet):
else: else:
raise PermissionDenied() raise PermissionDenied()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet): class EventOrderViewSet(OrderViewSetMixin, viewsets.ModelViewSet):
permission = 'can_view_orders' permission = 'can_view_orders'
+69 -8
View File
@@ -43,18 +43,16 @@ from pretix.api.serializers.organizer import (
CustomerCreateSerializer, CustomerSerializer, DeviceSerializer, CustomerCreateSerializer, CustomerSerializer, DeviceSerializer,
GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer, GiftCardSerializer, GiftCardTransactionSerializer, MembershipSerializer,
MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer, MembershipTypeSerializer, OrganizerSerializer, OrganizerSettingsSerializer,
SeatingPlanSerializer, TeamAPITokenSerializer, TeamInviteSerializer, SalesChannelSerializer, SeatingPlanSerializer, TeamAPITokenSerializer,
TeamMemberSerializer, TeamSerializer, TeamInviteSerializer, TeamMemberSerializer, TeamSerializer,
) )
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, GiftCard, GiftCardTransaction, Membership, Customer, Device, GiftCard, GiftCardTransaction, Membership,
MembershipType, Organizer, SeatingPlan, Team, TeamAPIToken, TeamInvite, MembershipType, Organizer, SalesChannel, SeatingPlan, Team, TeamAPIToken,
User, TeamInvite, User,
) )
from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.helpers import OF_SELF from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
from pretix.presale.style import regenerate_organizer_css
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -504,8 +502,6 @@ class OrganizerSettingsView(views.APIView):
k: v for k, v in s.validated_data.items() k: v for k, v in s.validated_data.items()
} }
) )
if any(p in s.changed_data for p in SETTINGS_AFFECTING_CSS):
regenerate_organizer_css.apply_async(args=(request.organizer.pk,))
s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={ s = OrganizerSettingsSerializer(instance=request.organizer.settings, organizer=request.organizer, context={
'request': request 'request': request
}) })
@@ -679,3 +675,68 @@ class MembershipViewSet(viewsets.ModelViewSet):
data=self.request.data, data=self.request.data,
) )
return inst return inst
with scopes_disabled():
class SalesChannelFilter(FilterSet):
class Meta:
model = SalesChannel
fields = ['type', 'identifier']
class SalesChannelViewSet(viewsets.ModelViewSet):
serializer_class = SalesChannelSerializer
queryset = SalesChannel.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
filter_backends = (DjangoFilterBackend,)
filterset_class = SalesChannelFilter
lookup_field = 'identifier'
lookup_url_kwarg = 'identifier'
lookup_value_regex = r"[a-zA-Z0-9.\-_]+"
def get_queryset(self):
return self.request.organizer.sales_channels.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
@transaction.atomic()
def perform_create(self, serializer):
inst = serializer.save(
organizer=self.request.organizer,
type="api"
)
inst.log_action(
'pretix.saleschannel.created',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
@transaction.atomic()
def perform_update(self, serializer):
inst = serializer.save(
type=serializer.instance.type,
identifier=serializer.instance.identifier,
)
inst.log_action(
'pretix.sales_channel.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
def perform_destroy(self, instance):
if not instance.allow_delete():
raise PermissionDenied("Can only be deleted if unused.")
instance.log_action(
'pretix.saleschannel.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()
+85 -25
View File
@@ -20,56 +20,83 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
import logging import logging
import warnings
from collections import OrderedDict from collections import OrderedDict
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from pretix.base.signals import register_sales_channels from pretix.base.signals import (
register_sales_channel_types, register_sales_channels,
)
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ALL_CHANNELS = None _ALL_CHANNEL_TYPES = None
class SalesChannel: class SalesChannelType:
def __repr__(self): def __repr__(self):
return '<SalesChannel: {}>'.format(self.identifier) return '<SalesChannelType: {}>'.format(self.identifier)
@property @property
def identifier(self) -> str: def identifier(self) -> str:
""" """
The internal identifier of this sales channel. The internal identifier of this sales channel type.
""" """
raise NotImplementedError() # NOQA raise NotImplementedError() # NOQA
@property @property
def verbose_name(self) -> str: def verbose_name(self) -> str:
""" """
A human-readable name of this sales channel. A human-readable name of this sales channel type.
""" """
raise NotImplementedError() # NOQA raise NotImplementedError() # NOQA
@property
def description(self) -> str:
"""
A human-readable description of this sales channel type.
"""
return ""
@property @property
def icon(self) -> str: def icon(self) -> str:
""" """
The name of a Font Awesome icon to represent this channel This can be:
- The name of a Font Awesome icon to represent this channel type.
- The name of a SVG icon file that is resolvable through the static file system. We recommend to design for a size of 18x14 pixels.
""" """
return "circle" return "circle"
@property
def default_created(self) -> bool:
"""
Indication, if a sales channel of this type should automatically be created for every organizer
"""
return True
@property
def multiple_allowed(self) -> bool:
"""
Indication, if multiple sales channels of this type may exist in the same organizer
"""
return False
@property @property
def testmode_supported(self) -> bool: def testmode_supported(self) -> bool:
""" """
Indication, if a saleschannels supports test mode orders Indication, if a sales channel of this type supports test mode orders
""" """
return True return True
@property @property
def payment_restrictions_supported(self) -> bool: def payment_restrictions_supported(self) -> bool:
""" """
If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel. If this property is ``True``, organizers can restrict the usage of payment providers to this sales channel type.
Example: pretixPOS provides its own sales channel, ignores the configured payment providers completely and Example: pretixPOS provides its own sales channel type, ignores the configured payment providers completely and
handles payments locally. Therefor, this property should be set to ``False`` for the pretixPOS sales channel as handles payments locally. Therefore, this property should be set to ``False`` for the pretixPOS sales channel as
the event organizer cannot restrict the usage of any payment provider through the backend. the event organizer cannot restrict the usage of any payment provider through the backend.
""" """
return True return True
@@ -77,8 +104,8 @@ class SalesChannel:
@property @property
def unlimited_items_per_order(self) -> bool: def unlimited_items_per_order(self) -> bool:
""" """
If this property is ``True``, purchases made using this sales channel are not limited to the maximum amount of If this property is ``True``, purchases made using sales channels of this type are not limited to the maximum
items defined in the event settings. amount of items defined in the event settings.
""" """
return False return False
@@ -96,34 +123,67 @@ class SalesChannel:
""" """
return True return True
@property
def required_event_plugin(self) -> str:
"""
Name of an event plugin that is required for this sales channel to be useful. Defaults to ``None``.
"""
return
def get_all_sales_channels():
global _ALL_CHANNELS
if _ALL_CHANNELS: def get_all_sales_channel_types():
return _ALL_CHANNELS from pretix.base.signals import register_sales_channel_types
global _ALL_CHANNEL_TYPES
if _ALL_CHANNEL_TYPES:
return _ALL_CHANNEL_TYPES
channels = [] channels = []
for recv, ret in register_sales_channels.send(None): for recv, ret in register_sales_channel_types.send(None):
if isinstance(ret, (list, tuple)):
channels += ret
else:
channels.append(ret)
for recv, ret in register_sales_channels.send(None): # todo: remove me
if isinstance(ret, (list, tuple)): if isinstance(ret, (list, tuple)):
channels += ret channels += ret
else: else:
channels.append(ret) channels.append(ret)
channels.sort(key=lambda c: c.identifier) channels.sort(key=lambda c: c.identifier)
_ALL_CHANNELS = OrderedDict([(c.identifier, c) for c in channels]) _ALL_CHANNEL_TYPES = OrderedDict([(c.identifier, c) for c in channels])
if 'web' in _ALL_CHANNELS: if 'web' in _ALL_CHANNEL_TYPES:
_ALL_CHANNELS.move_to_end('web', last=False) _ALL_CHANNEL_TYPES.move_to_end('web', last=False)
return _ALL_CHANNELS return _ALL_CHANNEL_TYPES
class WebshopSalesChannel(SalesChannel): def get_all_sales_channels():
# TODO: remove me
warnings.warn('Using get_all_sales_channels() is no longer appropriate, use get_al_sales_channel_types() instead.',
DeprecationWarning, stacklevel=2)
return get_all_sales_channel_types()
class WebshopSalesChannelType(SalesChannelType):
identifier = "web" identifier = "web"
verbose_name = _('Online shop') verbose_name = _('Online shop')
icon = "globe" icon = "globe"
@receiver(register_sales_channels, dispatch_uid="base_register_default_sales_channels") class ApiSalesChannelType(SalesChannelType):
identifier = "api"
verbose_name = _('API')
description = _('API sales channels come with no built-in functionality, but may be used for custom integrations.')
icon = "exchange"
default_created = False
multiple_allowed = True
SalesChannel = SalesChannelType # TODO: remove me
@receiver(register_sales_channel_types, dispatch_uid="base_register_default_sales_channel_types")
def base_sales_channels(sender, **kwargs): def base_sales_channels(sender, **kwargs):
return ( return (
WebshopSalesChannel(), WebshopSalesChannelType(),
ApiSalesChannelType(),
) )
+12 -4
View File
@@ -27,7 +27,6 @@ from openpyxl.styles import Alignment
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
from ...helpers.safe_openpyxl import SafeCell from ...helpers.safe_openpyxl import SafeCell
from ..channels import get_all_sales_channels
from ..exporter import ListExporter from ..exporter import ListExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..signals import register_data_exporters from ..signals import register_data_exporters
@@ -53,7 +52,7 @@ class ItemDataExporter(ListExporter):
def iterate_list(self, form_data): def iterate_list(self, form_data):
locales = self.event.settings.locales locales = self.event.settings.locales
scs = get_all_sales_channels() scs = self.organizer.sales_channels.all()
header = [ header = [
_("Product ID"), _("Product ID"),
_("Variation ID"), _("Variation ID"),
@@ -141,9 +140,15 @@ class ItemDataExporter(ListExporter):
row.append(i.name.localize(l)) row.append(i.name.localize(l))
for l in locales: for l in locales:
row.append(v.value.localize(l)) row.append(v.value.localize(l))
sales_channels = list(scs)
if not i.all_sales_channels:
sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in i.limit_sales_channels.all())]
if not v.all_sales_channels:
sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in v.limit_sales_channels.all())]
row += [ row += [
_("Yes") if i.active and v.active else "", _("Yes") if i.active and v.active else "",
", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels and s in v.sales_channels]), ", ".join([str(sn.label) for sn in sales_channels]),
v.default_price or i.default_price, v.default_price or i.default_price,
_("Yes") if i.free_price else "", _("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "", str(i.tax_rule) if i.tax_rule else "",
@@ -186,9 +191,12 @@ class ItemDataExporter(ListExporter):
row.append(i.name.localize(l)) row.append(i.name.localize(l))
for l in locales: for l in locales:
row.append("") row.append("")
sales_channels = list(scs)
if not i.all_sales_channels:
sales_channels = [s for s in sales_channels if s.identifier in (p.identifier for p in i.limit_sales_channels.all())]
row += [ row += [
_("Yes") if i.active else "", _("Yes") if i.active else "",
", ".join([str(sn.verbose_name) for s, sn in scs.items() if s in i.sales_channels]), ", ".join([str(sn.label) for sn in sales_channels]),
i.default_price, i.default_price,
_("Yes") if i.free_price else "", _("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "", str(i.tax_rule) if i.tax_rule else "",
+8 -3
View File
@@ -54,6 +54,7 @@ class JSONExporter(BaseExporter):
'import in third-party systems.') 'import in third-party systems.')
def render(self, form_data): def render(self, form_data):
all_sales_channels = self.event.organizer.sales_channels.all()
jo = { jo = {
'event': { 'event': {
'name': str(self.event.name), 'name': str(self.event.name),
@@ -85,7 +86,7 @@ class JSONExporter(BaseExporter):
'admission': item.admission, 'admission': item.admission,
'personalized': item.personalized, 'personalized': item.personalized,
'active': item.active, 'active': item.active,
'sales_channels': item.sales_channels, 'sales_channels': [c.identifier for c in (all_sales_channels if item.all_sales_channels else item.limit_sales_channels.all())],
'description': str(item.description), 'description': str(item.description),
'available_from': item.available_from, 'available_from': item.available_from,
'available_until': item.available_until, 'available_until': item.available_until,
@@ -114,7 +115,9 @@ class JSONExporter(BaseExporter):
'checkin_text': variation.checkin_text, 'checkin_text': variation.checkin_text,
'require_approval': variation.require_approval, 'require_approval': variation.require_approval,
'require_membership': variation.require_membership, 'require_membership': variation.require_membership,
'sales_channels': variation.sales_channels, 'sales_channels': [
c.identifier for c in (all_sales_channels if variation.all_sales_channels else variation.limit_sales_channels.all())
],
'available_from': variation.available_from, 'available_from': variation.available_from,
'available_until': variation.available_until, 'available_until': variation.available_until,
'hide_without_voucher': variation.hide_without_voucher, 'hide_without_voucher': variation.hide_without_voucher,
@@ -122,6 +125,7 @@ class JSONExporter(BaseExporter):
} for variation in item.variations.all() } for variation in item.variations.all()
] ]
} for item in self.event.items.select_related('tax_rule').prefetch_related( } for item in self.event.items.select_related('tax_rule').prefetch_related(
'limit_sales_channels',
Prefetch( Prefetch(
'meta_values', 'meta_values',
ItemMetaValue.objects.select_related('property'), ItemMetaValue.objects.select_related('property'),
@@ -130,6 +134,7 @@ class JSONExporter(BaseExporter):
Prefetch( Prefetch(
'variations', 'variations',
queryset=ItemVariation.objects.prefetch_related( queryset=ItemVariation.objects.prefetch_related(
'limit_sales_channels',
Prefetch( Prefetch(
'meta_values', 'meta_values',
ItemVariationMetaValue.objects.select_related('property'), ItemVariationMetaValue.objects.select_related('property'),
@@ -167,7 +172,7 @@ class JSONExporter(BaseExporter):
'require_approval': order.require_approval, 'require_approval': order.require_approval,
'checkin_attention': order.checkin_attention, 'checkin_attention': order.checkin_attention,
'checkin_text': order.checkin_text, 'checkin_text': order.checkin_text,
'sales_channel': order.sales_channel, 'sales_channel': order.sales_channel.identifier,
'expires': order.expires, 'expires': order.expires,
'datetime': order.datetime, 'datetime': order.datetime,
'fees': [ 'fees': [
+20 -1
View File
@@ -37,6 +37,7 @@ from decimal import Decimal
from zoneinfo import ZoneInfo from zoneinfo import ZoneInfo
from django import forms from django import forms
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, F, IntegerField, Max, Min, OuterRef,
Q, Subquery, Sum, When, Q, Subquery, Sum, When,
@@ -54,7 +55,7 @@ from openpyxl.comments import Comment
from openpyxl.styles import Font, PatternFill from openpyxl.styles import Font, PatternFill
from pretix.base.models import ( from pretix.base.models import (
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order, Checkin, GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
OrderPosition, Question, OrderPosition, Question,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
@@ -541,6 +542,22 @@ class OrderListExporter(MultiSheetListExporter):
).order_by() ).order_by()
qs = base_qs.annotate( qs = base_qs.annotate(
payment_providers=Subquery(p_providers, output_field=CharField()), payment_providers=Subquery(p_providers, output_field=CharField()),
checked_in_lists=Subquery(
Checkin.objects.filter(
successful=True,
type=Checkin.TYPE_ENTRY,
position=OuterRef("pk"),
).order_by().values("position").annotate(
c=GroupConcat(
"list__name",
# These appear not to work properly on SQLite. Well, we don't support SQLite outside testing
# anyways.
ordered='sqlite' not in settings.DATABASES['default']['ENGINE'],
distinct='sqlite' not in settings.DATABASES['default']['ENGINE'],
delimiter=", "
)
).values("c")
),
).select_related( ).select_related(
'order', 'order__invoice_address', 'order__customer', 'item', 'variation', 'order', 'order__invoice_address', 'order__customer', 'item', 'variation',
'voucher', 'tax_rule' 'voucher', 'tax_rule'
@@ -638,6 +655,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Sales channel'), _('Order locale'), _('Sales channel'), _('Order locale'),
_('E-mail address verified'), _('E-mail address verified'),
_('External customer ID'), _('External customer ID'),
_('Check-in lists'),
_('Payment providers'), _('Payment providers'),
] ]
@@ -776,6 +794,7 @@ class OrderListExporter(MultiSheetListExporter):
_('Yes') if order.email_known_to_work else _('No'), _('Yes') if order.email_known_to_work else _('No'),
str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '', str(order.customer.external_identifier) if order.customer and order.customer.external_identifier else '',
] ]
row.append(op.checked_in_lists or "")
row.append(', '.join([ row.append(', '.join([
str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(','))) str(self.providers.get(p, p)) for p in sorted(set((op.payment_providers or '').split(',')))
if p and p != 'free' if p and p != 'free'
-112
View File
@@ -1,112 +0,0 @@
from bootstrap3.renderers import FieldRenderer
from bootstrap3.text import text_value
from django.forms import CheckboxInput, CheckboxSelectMultiple, RadioSelect
from django.forms.utils import flatatt
from django.utils.html import format_html
from django.utils.safestring import mark_safe
from django.utils.translation import pgettext
def render_label(content, label_for=None, label_class=None, label_title='', label_id='', optional=False, is_valid=None, attrs=None):
"""
Render a label with content
"""
attrs = attrs or {}
if label_for:
attrs['for'] = label_for
if label_class:
attrs['class'] = label_class
if label_title:
attrs['title'] = label_title
if label_id:
attrs['id'] = label_id
opt = ""
if is_valid is not None:
if is_valid:
validation_text = pgettext('form', 'is valid')
else:
validation_text = pgettext('form', 'has errors')
opt += '<strong class="sr-only"> {}</strong>'.format(validation_text)
if text_value(content) == '&#160;':
# Empty label, e.g. checkbox
attrs.setdefault('class', '')
attrs['class'] += ' label-empty'
# usually checkboxes have overall empty labels and special labels per checkbox
# => remove for-attribute as well as "required"-text appended to label
if 'for' in attrs:
del attrs['for']
else:
opt += '<i class="sr-only label-required">, {}</i>'.format(pgettext('form', 'required')) if not optional else ''
builder = '<{tag}{attrs}>{content}{opt}</{tag}>'
return format_html(
builder,
tag='label',
attrs=mark_safe(flatatt(attrs)) if attrs else '',
opt=mark_safe(opt),
content=text_value(content),
)
class PretixFieldRenderer(FieldRenderer):
def __init__(self, *args, **kwargs):
kwargs['layout'] = 'horizontal'
super().__init__(*args, **kwargs)
self.is_group_widget = isinstance(self.widget, (CheckboxSelectMultiple, RadioSelect, )) or (self.is_multi_widget and len(self.widget.widgets) > 1)
def add_label(self, html):
attrs = {}
label = self.get_label()
if hasattr(self.field.field, '_show_required'):
# e.g. payment settings forms where a field is only required if the payment provider is active
required = self.field.field._show_required
elif hasattr(self.field.field, '_required'):
# e.g. payment settings forms where a field is only required if the payment provider is active
required = self.field.field._required
else:
required = self.field.field.required
if self.field.form.is_bound:
is_valid = len(self.field.errors) == 0
else:
is_valid = None
if self.is_group_widget:
label_for = ""
label_id = "legend-{}".format(self.field.html_name)
else:
label_for = self.field.id_for_label
label_id = ""
if hasattr(self.field.field, 'question') and self.field.field.question.identifier:
attrs["data-identifier"] = self.field.field.question.identifier
html = render_label(
label,
label_for=label_for,
label_class=self.get_label_class(),
label_id=label_id,
attrs=attrs,
optional=not required and not isinstance(self.widget, CheckboxInput),
is_valid=is_valid
) + html
return html
def wrap_label_and_field(self, html):
if self.is_group_widget:
attrs = ' role="group" aria-labelledby="legend-{}"'.format(self.field.html_name)
else:
attrs = ''
return '<div class="{klass}"{attrs}>{html}</div>'.format(klass=self.get_form_group_class(), html=html, attrs=attrs)
def wrap_widget(self, html):
if isinstance(self.widget, CheckboxInput):
css_class = "checkbox"
if self.field.field.disabled:
css_class += " disabled"
html = f'<div class="{css_class}">{html}</div>'
return html
-2
View File
@@ -256,8 +256,6 @@ class SecurityMiddleware(MiddlewareMixin):
# pages # pages
return resp return resp
resp['X-XSS-Protection'] = '1'
# We just need to have a P3P, not matter whats in there # We just need to have a P3P, not matter whats in there
# https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/ # https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/
# https://github.com/pretix/pretix/issues/765 # https://github.com/pretix/pretix/issues/765
@@ -2,7 +2,7 @@
from django.db import migrations from django.db import migrations
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channel_types
def set_sales_channels(apps, schema_editor): def set_sales_channels(apps, schema_editor):
@@ -11,7 +11,7 @@ def set_sales_channels(apps, schema_editor):
# Therefore, for existing events, we enable all sales channels # Therefore, for existing events, we enable all sales channels
Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore') Event_SettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
Event = apps.get_model('pretixbase', 'Event') Event = apps.get_model('pretixbase', 'Event')
all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channels()) + "]" all_sales_channels = "[" + ", ".join('"' + sc + '"' for sc in get_all_sales_channel_types()) + "]"
batch_size = 1000 batch_size = 1000
Event_SettingsStore.objects.bulk_create([ Event_SettingsStore.objects.bulk_create([
Event_SettingsStore( Event_SettingsStore(
@@ -3,7 +3,7 @@
from django.db import migrations from django.db import migrations
import pretix.base.models.fields import pretix.base.models.fields
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channel_types
class Migration(migrations.Migration): class Migration(migrations.Migration):
@@ -15,6 +15,6 @@ class Migration(migrations.Migration):
migrations.AddField( migrations.AddField(
model_name='event', model_name='event',
name='sales_channels', name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channels().keys())), field=pretix.base.models.fields.MultiStringField(default=list(get_all_sales_channel_types().keys())),
), ),
] ]
@@ -0,0 +1,110 @@
# Generated by Django 4.2.8 on 2024-03-24 17:43
import django.db.models.deletion
import i18nfield.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0264_order_internal_secret"),
]
operations = [
migrations.CreateModel(
name="SalesChannel",
fields=[
(
"id",
models.BigAutoField(
auto_created=True, primary_key=True, serialize=False
),
),
("label", i18nfield.fields.I18nCharField(max_length=200)),
("identifier", models.CharField(max_length=200)),
("type", models.CharField(max_length=200)),
("position", models.PositiveIntegerField(default=0)),
("configuration", models.JSONField(default=dict)),
],
),
migrations.RenameField(
model_name="checkinlist",
old_name="auto_checkin_sales_channels",
new_name="auto_checkin_sales_channel_types",
),
migrations.AddField(
model_name="discount",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="event",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="item",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.AddField(
model_name="itemvariation",
name="all_sales_channels",
field=models.BooleanField(default=True),
),
migrations.RenameField(
model_name="order",
old_name="sales_channel",
new_name="sales_channel_type",
),
migrations.AddField(
model_name="saleschannel",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="sales_channels",
to="pretixbase.organizer",
),
),
migrations.AddField(
model_name="discount",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="event",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="item",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="itemvariation",
name="limit_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="checkinlist",
name="auto_checkin_sales_channels",
field=models.ManyToManyField(to="pretixbase.saleschannel"),
),
migrations.AddField(
model_name="order",
name="sales_channel",
field=models.ForeignKey(
null=True,
on_delete=django.db.models.deletion.PROTECT,
to="pretixbase.saleschannel",
),
),
migrations.AlterUniqueTogether(
name="saleschannel",
unique_together={("organizer", "identifier")},
),
]
@@ -0,0 +1,84 @@
# Generated by Django 4.2.8 on 2024-03-24 17:55
from django.db import migrations
from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channel_types
def create_sales_channels(apps, schema_editor):
channel_types = get_all_sales_channel_types()
type_to_channel = dict()
full_discount_set = set()
full_set = set()
Organizer = apps.get_model("pretixbase", "Organizer")
for o in Organizer.objects.all().iterator():
for i, t in enumerate(channel_types.values()):
if not t.default_created:
continue
type_to_channel[t.identifier, o.pk] = o.sales_channels.get_or_create(
type=t.identifier,
defaults=dict(
position=i,
identifier=t.identifier,
label=LazyI18nString.from_gettext(t.verbose_name),
),
)[0]
full_set.add(t.identifier)
if t.discounts_supported:
full_discount_set.add(t.identifier)
Event = apps.get_model("pretixbase", "Event")
for d in Event.objects.all().iterator():
if set(d.sales_channels) != full_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.organizer_id])
Item = apps.get_model("pretixbase", "Item")
for d in Item.objects.select_related("event").iterator():
if set(d.sales_channels) != full_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.event.organizer_id])
ItemVariation = apps.get_model("pretixbase", "ItemVariation")
for d in ItemVariation.objects.select_related("item__event").iterator():
if set(d.sales_channels) != full_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.item.event.organizer_id])
Discount = apps.get_model("pretixbase", "Discount")
for d in Discount.objects.select_related("event").iterator():
if set(d.sales_channels) != full_discount_set:
d.all_sales_channels = False
d.save()
for s in d.sales_channels:
d.limit_sales_channels.add(type_to_channel[s, d.event.organizer_id])
CheckinList = apps.get_model("pretixbase", "CheckinList")
for c in CheckinList.objects.select_related("event").iterator():
for s in c.auto_checkin_sales_channel_types:
c.auto_checkin_sales_channels.add(type_to_channel[s, c.event.organizer_id])
Order = apps.get_model("pretixbase", "Order")
for (k, orgid), v in type_to_channel.items():
Order.objects.filter(sales_channel_type=k, event__organizer_id=orgid, sales_channel__isnull=True).update(
sales_channel=v
)
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0265_saleschannel_and_more"),
]
operations = [
migrations.RunPython(create_sales_channels, migrations.RunPython.noop),
]
@@ -0,0 +1,46 @@
# Generated by Django 4.2.8 on 2024-03-25 13:34
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0266_saleschannel_migrate_data"),
]
operations = [
migrations.RemoveField(
model_name="checkinlist",
name="auto_checkin_sales_channel_types",
),
migrations.RemoveField(
model_name="discount",
name="sales_channels",
),
migrations.RemoveField(
model_name="event",
name="sales_channels",
),
migrations.RemoveField(
model_name="item",
name="sales_channels",
),
migrations.RemoveField(
model_name="itemvariation",
name="sales_channels",
),
migrations.RemoveField(
model_name="order",
name="sales_channel_type",
),
migrations.AlterField(
model_name="order",
name="sales_channel",
field=models.ForeignKey(
on_delete=django.db.models.deletion.PROTECT,
to="pretixbase.saleschannel",
),
),
]
@@ -0,0 +1,31 @@
# Generated by Django 4.2.8 on 2024-07-01 09:26
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
("pretixbase", "0267_remove_old_sales_channels"),
]
operations = [
migrations.RemoveField(
model_name="subevent",
name="items",
),
migrations.RemoveField(
model_name="subevent",
name="variations",
),
migrations.AlterField(
model_name="order",
name="internal_secret",
field=models.CharField(
default=pretix.base.models.orders.generate_secret,
max_length=32,
null=True,
),
),
]
@@ -0,0 +1,54 @@
# Generated by Django 4.2.8 on 2024-07-01 09:27
from django.db import migrations, models
import django.db.models.deletion
class Migration(migrations.Migration):
dependencies = [
(
"pretixbase",
"0268_remove_subevent_items_remove_subevent_variations_and_more",
),
]
operations = [
migrations.RunSQL(
"UPDATE pretixbase_order "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_event e WHERE e.id = pretixbase_order.event_id) "
"WHERE pretixbase_order.id IN (SELECT id FROM pretixbase_order o2 WHERE o2.organizer_id IS NULL);"
),
migrations.RunSQL(
"UPDATE pretixbase_orderposition "
"SET organizer_id = (SELECT e.organizer_id FROM pretixbase_order o LEFT JOIN pretixbase_event e ON e.id = o.event_id WHERE o.id = pretixbase_orderposition.order_id) "
"WHERE pretixbase_orderposition.id IN (SELECT id FROM pretixbase_orderposition op2 WHERE op2.organizer_id IS NULL);"
),
migrations.AlterField(
model_name="order",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AlterField(
model_name="order",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="orders",
to="pretixbase.organizer",
),
),
migrations.AlterField(
model_name="orderposition",
name="organizer",
field=models.ForeignKey(
on_delete=django.db.models.deletion.CASCADE,
related_name="order_positions",
to="pretixbase.organizer",
),
),
]
+15 -6
View File
@@ -38,7 +38,6 @@ from i18nfield.strings import LazyI18nString
from phonenumber_field.phonenumber import to_python from phonenumber_field.phonenumber import to_python
from phonenumbers import SUPPORTED_REGIONS from phonenumbers import SUPPORTED_REGIONS
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.questions import guess_country from pretix.base.forms.questions import guess_country
from pretix.base.modelimport import ( from pretix.base.modelimport import (
DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin, DatetimeColumnMixin, DecimalColumnMixin, ImportColumn, SubeventColumnMixin,
@@ -538,18 +537,28 @@ class Expires(DatetimeColumnMixin, ImportColumn):
class Saleschannel(ImportColumn): class Saleschannel(ImportColumn):
identifier = 'sales_channel' identifier = 'sales_channel'
verbose_name = gettext_lazy('Sales channel') verbose_name = gettext_lazy('Sales channel')
default_value = None
initial = 'static:web'
@cached_property
def channels(self):
return list(self.event.organizer.sales_channels.all())
def static_choices(self): def static_choices(self):
return [ return [
(sc.identifier, sc.verbose_name) for sc in get_all_sales_channels().values() (c.identifier, str(c.label)) for c in self.channels
] ]
def clean(self, value, previous_values): def clean(self, value, previous_values):
if not value: matches = [
value = 'web' p for p in self.channels
if value not in get_all_sales_channels(): if p.identifier == value or any((v and v == value) for v in i18n_flat(p.label))
]
if len(matches) == 0:
raise ValidationError(_("Please enter a valid sales channel.")) raise ValidationError(_("Please enter a valid sales channel."))
return value if len(matches) > 1:
raise ValidationError(_("Please enter a valid sales channel."))
return matches[0]
def assign(self, value, order, position, invoice_address, **kwargs): def assign(self, value, order, position, invoice_address, **kwargs):
order.sales_channel = value order.sales_channel = value
+2 -1
View File
@@ -51,7 +51,8 @@ from .orders import (
generate_secret, generate_secret,
) )
from .organizer import ( from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, Organizer, Organizer_SettingsStore, SalesChannel, Team, TeamAPIToken,
TeamInvite,
) )
from .seating import Seat, SeatCategoryMapping, SeatingPlan from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule from .tax import TaxRule
+4 -5
View File
@@ -46,7 +46,6 @@ from django_scopes import ScopedManager, scopes_disabled
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.helpers import PostgresWindowFrame from pretix.helpers import PostgresWindowFrame
@@ -100,13 +99,13 @@ class CheckinList(LoggedModel):
verbose_name=_('Automatically check out everyone at'), verbose_name=_('Automatically check out everyone at'),
null=True, blank=True null=True, blank=True
) )
auto_checkin_sales_channels = MultiStringField( auto_checkin_sales_channels = models.ManyToManyField(
default=[], "SalesChannel",
blank=True,
verbose_name=_('Sales channels to automatically check in'), verbose_name=_('Sales channels to automatically check in'),
help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through ' help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through '
'any of the selected sales channels. This option can be useful when tickets sold at the box office ' 'any of the selected sales channels. This option can be useful when tickets sold at the box office '
'are not checked again before entry and should be considered validated directly upon purchase.') 'are not checked again before entry and should be considered validated directly upon purchase.'),
blank=True,
) )
rules = models.JSONField(default=dict, blank=True) rules = models.JSONField(default=dict, blank=True)
+8 -5
View File
@@ -34,7 +34,6 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager from django_scopes import ScopedManager
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import fields
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
@@ -65,10 +64,14 @@ class Discount(LoggedModel):
default=0, default=0,
verbose_name=_("Position") verbose_name=_("Position")
) )
sales_channels = fields.MultiStringField( all_sales_channels = models.BooleanField(
verbose_name=_('Sales channels'), verbose_name=_("All supported sales channels"),
default=['web'], default=True,
blank=False, )
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Sales channels"),
blank=True,
) )
available_from = models.DateTimeField( available_from = models.DateTimeField(
+66 -30
View File
@@ -36,6 +36,7 @@ import logging
import os import os
import string import string
import uuid import uuid
import warnings
from collections import Counter, OrderedDict, defaultdict from collections import Counter, OrderedDict, defaultdict
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
from operator import attrgetter from operator import attrgetter
@@ -66,7 +67,6 @@ from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
from pretix.base.timemachine import time_machine_now from pretix.base.timemachine import time_machine_now
from pretix.base.validators import EventSlugBanlistValidator from pretix.base.validators import EventSlugBanlistValidator
@@ -307,6 +307,7 @@ class EventMixin:
def annotated(cls, qs, channel='web', voucher=None): def annotated(cls, qs, channel='web', voucher=None):
from pretix.base.models import Item, ItemVariation, Quota from pretix.base.models import Item, ItemVariation, Quota
assert isinstance(channel, str)
sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).filter( sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel, voucher=voucher).filter(
Q(variations__isnull=True) Q(variations__isnull=True)
& Q(quotas__pk=OuterRef('pk')) & Q(quotas__pk=OuterRef('pk'))
@@ -316,14 +317,14 @@ class EventMixin:
q_variation = ( q_variation = (
Q(active=True) Q(active=True)
& Q(sales_channels__contains=channel) & Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel))
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now())) & Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now())) & Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()))
& Q(item__active=True) & Q(item__active=True)
& Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now())) & Q(Q(item__available_from__isnull=True) | Q(item__available_from__lte=time_machine_now()))
& Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now())) & Q(Q(item__available_until__isnull=True) | Q(item__available_until__gte=time_machine_now()))
& Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False)) & Q(Q(item__category__isnull=True) | Q(item__category__is_addon=False))
& Q(item__sales_channels__contains=channel) & Q(Q(item__all_sales_channels=True) | Q(item__limit_sales_channels__identifier=channel))
& Q(item__require_bundling=False) & Q(item__require_bundling=False)
& Q(quotas__pk=OuterRef('pk')) & Q(quotas__pk=OuterRef('pk'))
) )
@@ -467,6 +468,7 @@ class EventMixin:
return best_state_found, num_tickets_found, num_tickets_possible return best_state_found, num_tickets_found, num_tickets_possible
def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False): def free_seats(self, ignore_voucher=None, sales_channel='web', include_blocked=False):
assert isinstance(sales_channel, str) or sales_channel is None
qs_annotated = self._seats(ignore_voucher=ignore_voucher) qs_annotated = self._seats(ignore_voucher=ignore_voucher)
qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False) qs = qs_annotated.filter(has_order=False, has_cart=False, has_voucher=False)
@@ -495,10 +497,13 @@ class EventMixin:
return qs.filter(q) return qs.filter(q)
def default_sales_channels(): def default_sales_channels(): # kept for legacy migration
from ..channels import get_all_sales_channels from ..channels import get_all_sales_channel_types
return list(get_all_sales_channels().keys()) if "PYTEST_CURRENT_TEST" not in os.environ:
warnings.warn('Method should not be used in new code.', DeprecationWarning)
return list(get_all_sales_channel_types().keys())
@settings_hierarkey.add(parent_field='organizer', cache_namespace='event') @settings_hierarkey.add(parent_field='organizer', cache_namespace='event')
@@ -535,8 +540,10 @@ class Event(EventMixin, LoggedModel):
:type plugins: str :type plugins: str
:param has_subevents: Enable event series functionality :param has_subevents: Enable event series functionality
:type has_subevents: bool :type has_subevents: bool
:param sales_channels: A list of sales channel identifiers, that this event is available for sale on :param all_sales_channels: A flag indicating that this event is available on all channels and limit_sales_channels will be ignored.
:type sales_channels: list :type all_sales_channels: bool
:param limit_sales_channels: A list of sales channel identifiers, that this event is available for sale on
:type limit_sales_channels: list
""" """
settings_namespace = 'event' settings_namespace = 'event'
@@ -628,10 +635,14 @@ class Event(EventMixin, LoggedModel):
auto_now=True, db_index=True auto_now=True, db_index=True
) )
sales_channels = MultiStringField( all_sales_channels = models.BooleanField(
verbose_name=_('Restrict to specific sales channels'), verbose_name=_("Sell on all sales channels"),
help_text=_('Only sell tickets for this event on the following sales channels.'), default=True,
default=default_sales_channels, )
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
blank=True,
) )
objects = ScopedManager(organizer='organizer') objects = ScopedManager(organizer='organizer')
@@ -789,8 +800,6 @@ class Event(EventMixin, LoggedModel):
), tz) ), tz)
def copy_data_from(self, other, skip_meta_data=False): def copy_data_from(self, other, skip_meta_data=False):
from pretix.presale.style import regenerate_css
from ..signals import event_copy_data from ..signals import event_copy_data
from . import ( from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
@@ -807,10 +816,17 @@ class Event(EventMixin, LoggedModel):
if other.date_admission: if other.date_admission:
self.date_admission = self.date_from + (other.date_admission - other.date_from) self.date_admission = self.date_from + (other.date_admission - other.date_from)
self.testmode = other.testmode self.testmode = other.testmode
self.sales_channels = other.sales_channels self.all_sales_channels = other.all_sales_channels
self.save() self.save()
self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk}) self.log_action('pretix.object.cloned', data={'source': other.slug, 'source_id': other.pk})
if not self.all_sales_channels:
self.limit_sales_channels.set(
self.organizer.sales_channels.filter(
identifier__in=other.limit_sales_channels.values_list("identifier", flat=True)
)
)
if not skip_meta_data: if not skip_meta_data:
for emv in EventMetaValue.objects.filter(event=other): for emv in EventMetaValue.objects.filter(event=other):
emv.pk = None emv.pk = None
@@ -848,12 +864,17 @@ class Event(EventMixin, LoggedModel):
item_map = {} item_map = {}
variation_map = {} variation_map = {}
for i in Item.objects.filter(event=other).prefetch_related('variations'): for i in Item.objects.filter(event=other).prefetch_related(
'variations', 'limit_sales_channels', 'require_membership_types',
'variations__limit_sales_channels', 'variations__require_membership_types',
):
vars = list(i.variations.all()) vars = list(i.variations.all())
require_membership_types = list(i.require_membership_types.all()) require_membership_types = list(i.require_membership_types.all())
limit_sales_channels = list(i.limit_sales_channels.all())
item_map[i.pk] = i item_map[i.pk] = i
i.pk = None i.pk = None
i.event = self i.event = self
i._prefetched_objects_cache = {}
if i.picture: if i.picture:
i.picture.save(os.path.basename(i.picture.name), i.picture) i.picture.save(os.path.basename(i.picture.name), i.picture)
if i.category_id: if i.category_id:
@@ -870,12 +891,23 @@ class Event(EventMixin, LoggedModel):
if require_membership_types and other.organizer_id == self.organizer_id: if require_membership_types and other.organizer_id == self.organizer_id:
i.require_membership_types.set(require_membership_types) i.require_membership_types.set(require_membership_types)
if not i.all_sales_channels:
i.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
for v in vars: for v in vars:
require_membership_types = list(v.require_membership_types.all())
limit_sales_channels = list(v.limit_sales_channels.all())
variation_map[v.pk] = v variation_map[v.pk] = v
v.pk = None v.pk = None
v.item = i v.item = i
v._prefetched_objects_cache = {}
v.save(force_insert=True) v.save(force_insert=True)
if require_membership_types and other.organizer_id == self.organizer_id:
v.require_membership_types.set(require_membership_types)
if not v.all_sales_channels:
v.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
for i in self.items.filter(hidden_if_item_available__isnull=False): for i in self.items.filter(hidden_if_item_available__isnull=False):
i.hidden_if_item_available = item_map[i.hidden_if_item_available_id] i.hidden_if_item_available = item_map[i.hidden_if_item_available_id]
i.save() i.save()
@@ -913,6 +945,7 @@ class Event(EventMixin, LoggedModel):
vars = list(q.variations.all()) vars = list(q.variations.all())
oldid = q.pk oldid = q.pk
q.pk = None q.pk = None
q._prefetched_objects_cache = {}
q.event = self q.event = self
q.closed = False q.closed = False
q.save(force_insert=True) q.save(force_insert=True)
@@ -924,11 +957,15 @@ class Event(EventMixin, LoggedModel):
q.variations.add(variation_map[v.pk]) q.variations.add(variation_map[v.pk])
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q) self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
for d in Discount.objects.filter(event=other).prefetch_related('condition_limit_products'): for d in Discount.objects.filter(event=other).prefetch_related(
'condition_limit_products', 'benefit_limit_products', 'limit_sales_channels'
):
c_items = list(d.condition_limit_products.all()) c_items = list(d.condition_limit_products.all())
b_items = list(d.benefit_limit_products.all()) b_items = list(d.benefit_limit_products.all())
limit_sales_channels = list(v.limit_sales_channels.all())
d.pk = None d.pk = None
d.event = self d.event = self
d._prefetched_objects_cache = {}
d.save(force_insert=True) d.save(force_insert=True)
d.log_action('pretix.object.cloned') d.log_action('pretix.object.cloned')
for i in c_items: for i in c_items:
@@ -938,12 +975,16 @@ class Event(EventMixin, LoggedModel):
if i.pk in item_map: if i.pk in item_map:
d.benefit_limit_products.add(item_map[i.pk]) d.benefit_limit_products.add(item_map[i.pk])
if not d.all_sales_channels:
d.limit_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in limit_sales_channels]))
question_map = {} question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
items = list(q.items.all()) items = list(q.items.all())
opts = list(q.options.all()) opts = list(q.options.all())
question_map[q.pk] = q question_map[q.pk] = q
q.pk = None q.pk = None
q._prefetched_objects_cache = {}
q.event = self q.event = self
q.save(force_insert=True) q.save(force_insert=True)
q.log_action('pretix.object.cloned') q.log_action('pretix.object.cloned')
@@ -974,10 +1015,14 @@ class Event(EventMixin, LoggedModel):
_walk_rules(i) _walk_rules(i)
checkin_list_map = {} checkin_list_map = {}
for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related('limit_products'): for cl in other.checkin_lists.filter(subevent__isnull=True).prefetch_related(
'limit_products', 'auto_checkin_sales_channels'
):
items = list(cl.limit_products.all()) items = list(cl.limit_products.all())
auto_checkin_sales_channels = list(cl.auto_checkin_sales_channels.all())
checkin_list_map[cl.pk] = cl checkin_list_map[cl.pk] = cl
cl.pk = None cl.pk = None
cl._prefetched_objects_cache = {}
cl.event = self cl.event = self
rules = cl.rules rules = cl.rules
_walk_rules(rules) _walk_rules(rules)
@@ -986,6 +1031,8 @@ class Event(EventMixin, LoggedModel):
cl.log_action('pretix.object.cloned') cl.log_action('pretix.object.cloned')
for i in items: for i in items:
cl.limit_products.add(item_map[i.pk]) cl.limit_products.add(item_map[i.pk])
if auto_checkin_sales_channels:
cl.auto_checkin_sales_channels.set(self.organizer.sales_channels.filter(identifier__in=[s.identifier for s in auto_checkin_sales_channels]))
if other.seating_plan: if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id: if other.seating_plan.organizer_id == self.organizer_id:
@@ -1011,10 +1058,10 @@ class Event(EventMixin, LoggedModel):
s.product = item_map[s.product_id] s.product = item_map[s.product_id]
s.save(force_insert=True) s.save(force_insert=True)
has_custom_style = other.settings.presale_css_file or other.settings.presale_widget_css_file
skip_settings = ( skip_settings = (
'ticket_secrets_pretix_sig1_pubkey', 'ticket_secrets_pretix_sig1_pubkey',
'ticket_secrets_pretix_sig1_privkey', 'ticket_secrets_pretix_sig1_privkey',
# no longer used, but we still don't need to copy them
'presale_css_file', 'presale_css_file',
'presale_css_checksum', 'presale_css_checksum',
'presale_widget_css_file', 'presale_widget_css_file',
@@ -1057,9 +1104,6 @@ class Event(EventMixin, LoggedModel):
question_map=question_map, checkin_list_map=checkin_list_map, quota_map=quota_map, question_map=question_map, checkin_list_map=checkin_list_map, quota_map=quota_map,
) )
if has_custom_style:
regenerate_css.apply_async(args=(self.pk,))
def get_payment_providers(self, cached=False) -> dict: def get_payment_providers(self, cached=False) -> dict:
""" """
Returns a dictionary of initialized payment providers mapped by their identifiers. Returns a dictionary of initialized payment providers mapped by their identifiers.
@@ -1337,18 +1381,12 @@ class Event(EventMixin, LoggedModel):
def enable_plugin(self, module, allow_restricted=frozenset()): def enable_plugin(self, module, allow_restricted=frozenset()):
plugins_active = self.get_plugins() plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css
if module not in plugins_active: if module not in plugins_active:
plugins_active.append(module) plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted) self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
regenerate_css.apply_async(args=(self.pk,))
def disable_plugin(self, module): def disable_plugin(self, module):
plugins_active = self.get_plugins() plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css
if module in plugins_active: if module in plugins_active:
plugins_active.remove(module) plugins_active.remove(module)
self.set_active_plugins(plugins_active) self.set_active_plugins(plugins_active)
@@ -1357,8 +1395,6 @@ class Event(EventMixin, LoggedModel):
if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'): if module in plugins_available and hasattr(plugins_available[module].app, 'uninstalled'):
getattr(plugins_available[module].app, 'uninstalled')(self) getattr(plugins_available[module].app, 'uninstalled')(self)
regenerate_css.apply_async(args=(self.pk,))
@staticmethod @staticmethod
def clean_has_subevents(event, has_subevents): def clean_has_subevents(event, has_subevents):
if event is not None and event.has_subevents is not None: if event is not None and event.has_subevents is not None:
+45 -13
View File
@@ -34,8 +34,10 @@
# 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 calendar import calendar
import os
import sys import sys
import uuid import uuid
import warnings
from collections import Counter, OrderedDict from collections import Counter, OrderedDict
from datetime import date, datetime, time, timedelta from datetime import date, datetime, time, timedelta
from decimal import Decimal, DecimalException from decimal import Decimal, DecimalException
@@ -61,7 +63,6 @@ from django_countries.fields import Country
from django_scopes import ScopedManager from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields
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.tax import TaxedPrice from pretix.base.models.tax import TaxedPrice
@@ -122,6 +123,16 @@ class ItemCategory(LoggedModel):
return _('{category} (Add-On products)').format(category=str(name)) return _('{category} (Add-On products)').format(category=str(name))
return str(name) return str(name)
def get_category_type_display(self):
if self.is_addon:
return _('Add-On products')
else:
return None
@property
def category_type(self):
return 'addon' if self.is_addon else 'normal'
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.event: if self.event:
@@ -260,13 +271,15 @@ class SubEventItemVariation(models.Model):
def filter_available(qs, channel='web', voucher=None, allow_addons=False): def filter_available(qs, channel='web', voucher=None, allow_addons=False):
assert isinstance(channel, str)
q = ( q = (
# IMPORTANT: If this is updated, also update the ItemVariation query # IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated() # in models/event.py: EventMixin.annotated()
Q(active=True) Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info')) & Q(Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()) | Q(available_from_mode='info'))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info')) & Q(Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()) | Q(available_until_mode='info'))
& Q(sales_channels__contains=channel) & Q(require_bundling=False) & Q(Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=channel))
& Q(require_bundling=False)
) )
if not allow_addons: if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False)) q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
@@ -343,8 +356,10 @@ class Item(LoggedModel):
:type original_price: decimal.Decimal :type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator :param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
:type require_approval: bool :type require_approval: bool
:param sales_channels: Sales channels this item is available on. :param all_sales_channels: A flag indicating that this item is available on all channels and limit_sales_channels will be ignored.
:type sales_channels: bool :type all_sales_channels: bool
:param limit_sales_channels: A list of sales channel identifiers, that this item is available for sale on.
:type limit_sales_channels: list
:param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price :param issue_giftcard: If ``True``, buying this product will give you a gift card with the value of the product's price
:type issue_giftcard: bool :type issue_giftcard: bool
:param validity_mode: Instruction how to set ``valid_from``/``valid_until`` on tickets, ``null`` is default event validity. :param validity_mode: Instruction how to set ``valid_from``/``valid_until`` on tickets, ``null`` is default event validity.
@@ -599,9 +614,14 @@ class Item(LoggedModel):
help_text=_('If set, this will be displayed next to the current price to show that the current price is a ' help_text=_('If set, this will be displayed next to the current price to show that the current price is a '
'discounted one. This is just a cosmetic setting and will not actually impact pricing.') 'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
) )
sales_channels = fields.MultiStringField( all_sales_channels = models.BooleanField(
verbose_name=_('Sales channels'), verbose_name=_("Sell on all sales channels"),
default=['web'], default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
help_text=_('Only sell tickets for this product on the selected sales channels.'),
blank=True, blank=True,
) )
issue_giftcard = models.BooleanField( issue_giftcard = models.BooleanField(
@@ -1023,9 +1043,13 @@ class Item(LoggedModel):
return None, None return None, None
def _all_sales_channels_identifiers(): def _all_sales_channels_identifiers(): # kept for legacy migrations
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channel_types
return list(get_all_sales_channels().keys())
if "PYTEST_CURRENT_TEST" not in os.environ:
warnings.warn('Method should not be used in new code.', DeprecationWarning)
return list(get_all_sales_channel_types().keys())
class ItemVariation(models.Model): class ItemVariation(models.Model):
@@ -1048,6 +1072,10 @@ class ItemVariation(models.Model):
:param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after :param require_approval: If set to ``True``, orders containing this variation can only be processed and paid after
approval by an administrator approval by an administrator
:type require_approval: bool :type require_approval: bool
:param all_sales_channels: A flag indicating that this variation is available on all channels and limit_sales_channels will be ignored.
:type all_sales_channels: bool
:param limit_sales_channels: A list of sales channel identifiers, that this variation is available for sale on.
:type limit_sales_channels: list
""" """
item = models.ForeignKey( item = models.ForeignKey(
@@ -1133,9 +1161,13 @@ class ItemVariation(models.Model):
default=Item.UNAVAIL_MODE_HIDDEN, default=Item.UNAVAIL_MODE_HIDDEN,
max_length=16, max_length=16,
) )
sales_channels = fields.MultiStringField( all_sales_channels = models.BooleanField(
verbose_name=_('Sales channels'), verbose_name=_("Sell on all sales channels the product is sold on"),
default=_all_sales_channels_identifiers, default=True,
)
limit_sales_channels = models.ManyToManyField(
"SalesChannel",
verbose_name=_("Restrict to specific sales channels"),
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is ' help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'), 'selected here but not on product level, the variation will not be available.'),
blank=True, blank=True,
+7 -8
View File
@@ -187,8 +187,8 @@ class Order(LockModel, LoggedModel):
:type require_approval: bool :type require_approval: bool
:param meta_info: Additional meta information on the order, JSON-encoded. :param meta_info: Additional meta information on the order, JSON-encoded.
:type meta_info: str :type meta_info: str
:param sales_channel: Identifier of the sales channel this order was created through. :param sales_channel: Foreign key to the sales channel this order was created through.
:type sales_channel: str :type sales_channel: SalesChannel
""" """
STATUS_PENDING = "n" STATUS_PENDING = "n"
@@ -223,8 +223,6 @@ class Order(LockModel, LoggedModel):
"Organizer", "Organizer",
related_name="orders", related_name="orders",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
) )
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -305,7 +303,10 @@ class Order(LockModel, LoggedModel):
require_approval = models.BooleanField( require_approval = models.BooleanField(
default=False default=False
) )
sales_channel = models.CharField(max_length=190, default="web") sales_channel = models.ForeignKey(
"SalesChannel",
on_delete=models.PROTECT,
)
email_known_to_work = models.BooleanField( email_known_to_work = models.BooleanField(
default=False, default=False,
verbose_name=_('E-mail address verified') verbose_name=_('E-mail address verified')
@@ -1932,7 +1933,7 @@ class OrderPayment(models.Model):
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
) )
if send_mail and self.order.sales_channel in self.order.event.settings.mail_sales_channel_placed_paid: if send_mail and self.order.sales_channel.identifier in self.order.event.settings.mail_sales_channel_placed_paid:
self._send_paid_mail(invoice, user, mail_text) self._send_paid_mail(invoice, user, mail_text)
if self.order.event.settings.mail_send_order_paid_attendee: if self.order.event.settings.mail_send_order_paid_attendee:
for p in self.order.positions.all(): for p in self.order.positions.all():
@@ -2414,8 +2415,6 @@ class OrderPosition(AbstractPosition):
"Organizer", "Organizer",
related_name="order_positions", related_name="order_positions",
on_delete=models.CASCADE, on_delete=models.CASCADE,
null=True,
blank=True,
) )
order = models.ForeignKey( order = models.ForeignKey(
Order, Order,
+77
View File
@@ -46,7 +46,9 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django_scopes import ScopedManager, scope
from i18nfield.fields import I18nCharField from i18nfield.fields import I18nCharField
from i18nfield.strings import LazyI18nString
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.validators import OrganizerSlugBanlistValidator from pretix.base.validators import OrganizerSlugBanlistValidator
@@ -104,6 +106,8 @@ class Organizer(LoggedModel):
if is_new: if is_new:
kwargs.pop('update_fields', None) # does not make sense here kwargs.pop('update_fields', None) # does not make sense here
self.set_defaults() self.set_defaults()
with scope(organizer=self):
self.create_default_sales_channels()
else: else:
self.get_cache().clear() self.get_cache().clear()
return obj return obj
@@ -212,6 +216,24 @@ class Organizer(LoggedModel):
else: else:
return get_connection(fail_silently=False) return get_connection(fail_silently=False)
def create_default_sales_channels(self):
from pretix.base.channels import get_all_sales_channel_types
i = 0
for channel in get_all_sales_channel_types().values():
if not channel.default_created:
continue
self.sales_channels.get_or_create(
identifier=channel.identifier,
defaults={
'label': LazyI18nString.from_gettext(channel.verbose_name),
'type': channel.identifier,
},
position=i
)
i += 1
def generate_invite_token(): def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)
@@ -504,3 +526,58 @@ class OrganizerFooterLink(models.Model):
def save(self, *args, **kwargs): def save(self, *args, **kwargs):
super().save(*args, **kwargs) super().save(*args, **kwargs)
self.organizer.cache.clear() self.organizer.cache.clear()
class SalesChannel(LoggedModel):
organizer = models.ForeignKey('Organizer', on_delete=models.CASCADE, related_name='sales_channels')
label = I18nCharField(
max_length=200,
verbose_name=_("Name"),
)
identifier = models.CharField(
verbose_name=_("Identifier"),
max_length=200,
validators=[
RegexValidator(
regex=r"^[a-zA-Z0-9.\-_]+$",
message=_("The identifier may only contain letters, numbers, dots, dashes, and underscores."),
),
],
)
type = models.CharField(
verbose_name=_("Type"),
max_length=200,
)
position = models.PositiveIntegerField(
default=0,
verbose_name=_("Position")
)
configuration = models.JSONField(default=dict)
objects = ScopedManager(organizer="organizer")
class Meta:
ordering = ("position", "type", "identifier", "id")
unique_together = ("organizer", "identifier")
def __str__(self):
return str(self.label)
@cached_property
def type_instance(self):
from ..channels import get_all_sales_channel_types
types = get_all_sales_channel_types()
return types[self.type]
@property
def icon(self):
return self.type_instance.icon
def allow_delete(self):
from . import Order
if self.type_instance.default_created:
return False
return not Order.objects.filter(sales_channel=self).exists()
+5 -1
View File
@@ -243,10 +243,14 @@ class Seat(models.Model):
qs_annotated = qs_annotated.annotate(has_closeby_taken=Exists(sq_closeby)) qs_annotated = qs_annotated.annotate(has_closeby_taken=Exists(sq_closeby))
return qs_annotated return qs_annotated
def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None, sales_channel='web', def is_available(self, ignore_cart=None, ignore_orderpos=None, ignore_voucher_id=None,
sales_channel='web',
ignore_distancing=False, distance_ignore_cart_id=None): ignore_distancing=False, distance_ignore_cart_id=None):
from .orders import Order from .orders import Order
from .organizer import SalesChannel
if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier
if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel: if self.blocked and sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel:
return False return False
opqs = self.orderposition_set.filter( opqs = self.orderposition_set.filter(
+3 -4
View File
@@ -56,7 +56,6 @@ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator from pretix.base.forms import I18nMarkdownTextarea, PlaceholderValidator
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment, CartPosition, Event, GiftCard, InvoiceAddress, Order, OrderPayment,
@@ -417,8 +416,8 @@ class BasePaymentProvider:
forms.MultipleChoiceField( forms.MultipleChoiceField(
label=_('Restrict to specific sales channels'), label=_('Restrict to specific sales channels'),
choices=( choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values() (c.identifier, c.label) for c in self.event.organizer.sales_channels.all()
if c.payment_restrictions_supported if c.type_instance.payment_restrictions_supported
), ),
initial=['web'], initial=['web'],
widget=forms.CheckboxSelectMultiple, widget=forms.CheckboxSelectMultiple,
@@ -853,7 +852,7 @@ class BasePaymentProvider:
if str(ia.country) != '' and str(ia.country) not in restricted_countries: if str(ia.country) != '' and str(ia.country) not in restricted_countries:
return False return False
if order.sales_channel not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']): if order.sales_channel.identifier not in self.settings.get('_restrict_to_sales_channels', as_type=list, default=['web']):
return False return False
return self._is_available_by_time(order=order) return self._is_available_by_time(order=order)
+18 -3
View File
@@ -1092,10 +1092,20 @@ class Renderer:
fg_num_pages = fg_pdf.get_num_pages() fg_num_pages = fg_pdf.get_num_pages()
bg_num_pages = self.bg_pdf.get_num_pages() bg_num_pages = self.bg_pdf.get_num_pages()
bg_pdf_to_merge = PdfWriter() bg_pdf_to_merge = PdfWriter()
bg_pdf_to_merge.append(self.bg_pdf, pages=(0, min(bg_num_pages, fg_num_pages))) bg_pdf_to_merge.append(
self.bg_pdf,
pages=(0, min(bg_num_pages, fg_num_pages)),
import_outline=False,
excluded_fields=("/Annots", "/B")
)
if fg_num_pages > bg_num_pages: if fg_num_pages > bg_num_pages:
# repeat last page in bg_pdf to match fg_pdf # repeat last page in bg_pdf to match fg_pdf
bg_pdf_to_merge.append(bg_pdf_to_merge, pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages)) bg_pdf_to_merge.append(
bg_pdf_to_merge,
pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages),
import_outline=False,
excluded_fields=("/Annots", "/B")
)
bg_pdf_to_merge.write(bg_filename) bg_pdf_to_merge.write(bg_filename)
@@ -1162,7 +1172,12 @@ def merge_background(fg_pdf: PdfWriter, bg_pdf: PdfWriter, out_file, compress):
bg_num_pages = bg_pdf.get_num_pages() bg_num_pages = bg_pdf.get_num_pages()
if fg_num_pages > bg_num_pages: if fg_num_pages > bg_num_pages:
# repeat last page in bg_pdf to match fg_pdf # repeat last page in bg_pdf to match fg_pdf
bg_pdf.append(bg_pdf, pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages)) bg_pdf.append(
bg_pdf,
pages=[bg_num_pages - 1] * (fg_num_pages - bg_num_pages),
import_outline=False,
excluded_fields=("/Annots", "/B")
)
bg_pdf.write(bg_filename) bg_pdf.write(bg_filename)
+42 -10
View File
@@ -52,12 +52,11 @@ from django.utils.translation import (
) )
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat, CartPosition, Event, InvoiceAddress, Item, ItemVariation, SalesChannel,
SeatCategoryMapping, Voucher, Seat, SeatCategoryMapping, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee from pretix.base.models.orders import OrderFee
@@ -275,8 +274,8 @@ class CartManager:
AddOperation: 30 AddOperation: 30
} }
def __init__(self, event: Event, cart_id: str, invoice_address: InvoiceAddress=None, widget_data=None, def __init__(self, event: Event, cart_id: str, sales_channel: SalesChannel,
sales_channel='web'): invoice_address: InvoiceAddress=None, widget_data=None):
self.event = event self.event = event
self.cart_id = cart_id self.cart_id = cart_id
self.real_now_dt = now() self.real_now_dt = now()
@@ -384,7 +383,7 @@ class CartManager:
}) })
def _check_max_cart_size(self): def _check_max_cart_size(self):
if not get_all_sales_channels()[self._sales_channel].unlimited_items_per_order: if not self._sales_channel.type_instance.unlimited_items_per_order:
cartsize = self.positions.filter(addon_to__isnull=True).count() cartsize = self.positions.filter(addon_to__isnull=True).count()
cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to]) cartsize += sum([op.count for op in self._operations if isinstance(op, self.AddOperation) and not op.addon_to])
cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if cartsize -= len([1 for op in self._operations if isinstance(op, self.RemoveOperation) if
@@ -422,8 +421,13 @@ class CartManager:
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE: elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
raise CartError(error_messages['media_usage_not_implemented']) raise CartError(error_messages['media_usage_not_implemented'])
if self._sales_channel not in op.item.sales_channels or (op.variation and self._sales_channel not in op.variation.sales_channels): if not op.item.all_sales_channels:
raise CartError(error_messages['unavailable']) if self._sales_channel.identifier not in (s.identifier for s in op.item.limit_sales_channels.all()):
raise CartError(error_messages['unavailable'])
if op.variation and not op.variation.all_sales_channels:
if self._sales_channel.identifier not in (s.identifier for s in op.variation.limit_sales_channels.all()):
raise CartError(error_messages['unavailable'])
if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available(): if op.subevent and op.item.pk in op.subevent.item_overrides and not op.subevent.item_overrides[op.item.pk].is_available():
raise CartError(error_messages['not_for_sale']) raise CartError(error_messages['not_for_sale'])
@@ -457,7 +461,14 @@ class CartManager:
raise CartError(error_messages['ended']) raise CartError(error_messages['ended'])
seated = self._is_seated(op.item, op.subevent) seated = self._is_seated(op.item, op.subevent)
if seated and (not op.seat or (op.seat.blocked and self._sales_channel not in self.event.settings.seating_allow_blocked_seats_for_channel)): if (
seated and (
not op.seat or (
op.seat.blocked and
self._sales_channel.identifier not in self.event.settings.seating_allow_blocked_seats_for_channel
)
)
):
raise CartError(error_messages['seat_invalid']) raise CartError(error_messages['seat_invalid'])
elif op.seat and not seated: elif op.seat and not seated:
raise CartError(error_messages['seat_forbidden']) raise CartError(error_messages['seat_forbidden'])
@@ -1371,7 +1382,7 @@ class CartManager:
discount_results = apply_discounts( discount_results = apply_discounts(
self.event, self.event,
self._sales_channel, self._sales_channel.identifier,
[ [
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in positions for cp in positions
@@ -1505,6 +1516,11 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data, cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, widget_data=widget_data,
@@ -1526,6 +1542,10 @@ def apply_voucher(self, event: Event, voucher: str, cart_id: str=None, locale='e
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -1546,6 +1566,10 @@ def remove_cart_position(self, event: Event, position: int, cart_id: str=None, l
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -1565,6 +1589,10 @@ def clear_cart(self, event: Event, cart_id: str=None, locale='en', sales_channel
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale), time_machine_now_assigned(override_now_dt): with language(locale), time_machine_now_assigned(override_now_dt):
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, sales_channel=sales_channel)
@@ -1593,6 +1621,10 @@ def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, l
ia = InvoiceAddress.objects.get(pk=invoice_address) ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise CartError("Invalid sales channel.")
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel) cm = CartManager(event=event, cart_id=cart_id, invoice_address=ia, sales_channel=sales_channel)
+1 -1
View File
@@ -1159,7 +1159,7 @@ def order_placed(sender, **kwargs):
order = kwargs['order'] order = kwargs['order']
event = sender event = sender
cls = list(event.checkin_lists.filter(auto_checkin_sales_channels__contains=order.sales_channel).prefetch_related( cls = list(event.checkin_lists.filter(auto_checkin_sales_channels=order.sales_channel).prefetch_related(
'limit_products')) 'limit_products'))
if not cls: if not cls:
return return
+6 -3
View File
@@ -420,7 +420,7 @@ 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') or order.require_approval or \
order.sales_channel not in order.event.settings.get('invoice_generate_sales_channels'): order.sales_channel.identifier not in order.event.settings.get('invoice_generate_sales_channels'):
return False return False
return True return True
@@ -443,8 +443,11 @@ def build_preview_invoice_pdf(event):
locale = event.settings.locale locale = event.settings.locale
with rolledback_transaction(), language(locale, event.settings.region): with rolledback_transaction(), language(locale, event.settings.region):
order = event.orders.create(status=Order.STATUS_PENDING, datetime=timezone.now(), order = event.orders.create(
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count()) status=Order.STATUS_PENDING, datetime=timezone.now(),
expires=timezone.now(), code="PREVIEW", total=100 * event.tax_rules.count(),
sales_channel=event.organizer.sales_channels.get(identifier="web"),
)
invoice = Invoice( invoice = Invoice(
order=order, event=event, invoice_no="PREVIEW", order=order, event=event, invoice_no="PREVIEW",
date=timezone.now().date(), locale=locale, organizer=event.organizer date=timezone.now().date(), locale=locale, organizer=event.organizer
+44 -24
View File
@@ -62,7 +62,6 @@ from django.utils.translation import gettext as _, gettext_lazy, ngettext_lazy
from django_scopes import scopes_disabled from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication from pretix.api.models import OAuthApplication
from pretix.base.channels import get_all_sales_channels
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.i18n import get_language_without_region, language from pretix.base.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES from pretix.base.media import MEDIA_TYPES
@@ -76,7 +75,7 @@ from pretix.base.models.orders import (
BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund, BlockedTicketSecret, InvoiceAddress, OrderFee, OrderRefund,
generate_secret, generate_secret,
) )
from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.organizer import SalesChannel, TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.payment import GiftCardPayment, PaymentException from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
@@ -469,10 +468,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
order_denied.send(order.event, order=order) order_denied.send(order.event, order=order)
if send_mail: if send_mail:
email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment)
with language(order.locale, order.event.settings.region): with language(order.locale, order.event.settings.region):
email_template = order.event.settings.mail_text_order_denied
email_subject = order.event.settings.mail_subject_order_denied
email_context = get_email_context(event=order.event, order=order, comment=comment)
try: try:
order.send_mail( order.send_mail(
email_subject, email_template, email_context, email_subject, email_template, email_context,
@@ -650,8 +649,7 @@ def _check_date(event: Event, now_dt: datetime):
def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: datetime, positions: List[CartPosition], def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: datetime, positions: List[CartPosition],
address: InvoiceAddress = None, sales_channel: SalesChannel, address: InvoiceAddress=None, customer=None):
sales_channel='web', customer=None):
err = None err = None
_check_date(event, time_machine_now_dt) _check_date(event, time_machine_now_dt)
@@ -775,7 +773,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
if cp.seat: if cp.seat:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every # Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every
# time, since we absolutely can not overbook a seat. # time, since we absolutely can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel): if not cp.seat.is_available(ignore_cart=cp, ignore_voucher_id=cp.voucher_id, sales_channel=sales_channel.identifier):
err = err or error_messages['seat_unavailable'] err = err or error_messages['seat_unavailable']
delete(cp) delete(cp)
continue continue
@@ -873,7 +871,7 @@ def _check_positions(event: Event, now_dt: datetime, time_machine_now_dt: dateti
sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions] sorted_positions = [cp for cp in sorted_positions if cp.pk and cp.pk not in deleted_positions]
discount_results = apply_discounts( discount_results = apply_discounts(
event, event,
sales_channel, sales_channel.identifier,
[ [
(cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher) (cp.item_id, cp.subevent_id, cp.line_price_gross, bool(cp.addon_to), cp.is_bundled, cp.listed_price - cp.price_after_voucher)
for cp in sorted_positions for cp in sorted_positions
@@ -959,12 +957,11 @@ def _get_fees(positions: List[CartPosition], payment_requests: List[dict], addre
return fees return fees
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime, def _create_order(event: Event, *, email: str, positions: List[CartPosition], now_dt: datetime,
payment_requests: List[dict], locale: str=None, address: InvoiceAddress=None, payment_requests: List[dict], sales_channel: SalesChannel, locale: str=None,
meta_info: dict=None, sales_channel: str='web', shown_total=None, address: InvoiceAddress=None, meta_info: dict=None, shown_total=None,
customer=None, valid_if_pending=False): customer=None, valid_if_pending=False):
payments = [] payments = []
sales_channel = get_all_sales_channels()[sales_channel]
try: try:
validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode) validate_memberships_in_order(customer, positions, event, lock=True, testmode=event.testmode)
@@ -986,10 +983,10 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
datetime=now_dt, datetime=now_dt,
locale=get_language_without_region(locale), locale=get_language_without_region(locale),
total=total, total=total,
testmode=True if sales_channel.testmode_supported and event.testmode else False, testmode=True if sales_channel.type_instance.testmode_supported and event.testmode else False,
meta_info=json.dumps(meta_info or {}), meta_info=json.dumps(meta_info or {}),
require_approval=require_approval, require_approval=require_approval,
sales_channel=sales_channel.identifier, sales_channel=sales_channel,
customer=customer, customer=customer,
valid_if_pending=valid_if_pending, valid_if_pending=valid_if_pending,
) )
@@ -1108,6 +1105,11 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if customer: if customer:
customer = event.organizer.customers.get(pk=customer) customer = event.organizer.customers.get(pk=customer)
try:
sales_channel = event.organizer.sales_channels.get(identifier=sales_channel)
except SalesChannel.DoesNotExist:
raise OrderError("Invalid sales channel.")
if email == settings.PRETIX_EMAIL_NONE_VALUE: if email == settings.PRETIX_EMAIL_NONE_VALUE:
email = None email = None
@@ -1186,9 +1188,20 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
if 'sleep-after-quota-check' in debugflags_var.get(): if 'sleep-after-quota-check' in debugflags_var.get():
sleep(2) sleep(2)
order, payment_objs = _create_order(event, email, positions, real_now_dt, payment_requests, order, payment_objs = _create_order(
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel, event,
shown_total=shown_total, customer=customer, valid_if_pending=valid_if_pending) email=email,
positions=positions,
now_dt=real_now_dt,
payment_requests=payment_requests,
locale=locale,
address=addr,
meta_info=meta_info,
sales_channel=sales_channel,
shown_total=shown_total,
customer=customer,
valid_if_pending=valid_if_pending
)
try: try:
for p in payment_objs: for p in payment_objs:
@@ -1272,7 +1285,7 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
email_attendees_template = event.settings.mail_text_order_placed_attendee email_attendees_template = event.settings.mail_text_order_placed_attendee
subject_attendees_template = event.settings.mail_subject_order_placed_attendee subject_attendees_template = event.settings.mail_subject_order_placed_attendee
if sales_channel in event.settings.mail_sales_channel_placed_paid: if sales_channel.identifier in event.settings.mail_sales_channel_placed_paid:
_order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs, _order_placed_email(event, order, email_template, subject_template, log_entry, invoice, payment_objs,
is_free=free_order_flow) is_free=free_order_flow)
if email_attendees: if email_attendees:
@@ -1424,7 +1437,7 @@ def send_download_reminders(sender, **kwargs):
if days is None: if days is None:
continue continue
if o.sales_channel not in event.settings.mail_sales_channel_download_reminder: if o.sales_channel.identifier not in event.settings.mail_sales_channel_download_reminder:
continue continue
reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0) reminder_date = (o.first_date - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
@@ -1926,9 +1939,13 @@ class OrderChangeManager:
if not item.is_available() or (variation and not variation.is_available()): if not item.is_available() or (variation and not variation.is_available()):
raise OrderError(error_messages['unavailable']) raise OrderError(error_messages['unavailable'])
if self.order.sales_channel not in item.sales_channels or ( if not item.all_sales_channels:
variation and self.order.sales_channel not in variation.sales_channels): if self.order.sales_channel.identifier not in (s.identifier for s in item.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable']) raise OrderError(error_messages['unavailable'])
if variation and not variation.all_sales_channels:
if self.order.sales_channel.identifier not in (s.identifier for s in variation.limit_sales_channels.all()):
raise OrderError(error_messages['unavailable'])
if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available(): if subevent and item.pk in subevent.item_overrides and not subevent.item_overrides[item.pk].is_available():
raise OrderError(error_messages['not_for_sale']) raise OrderError(error_messages['not_for_sale'])
@@ -2027,7 +2044,10 @@ class OrderChangeManager:
(a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent)) (a.variation and a.variation.unavailability_reason(has_voucher=True, subevent=a.subevent))
or (a.variation and self.order.sales_channel not in a.variation.sales_channels) or (a.variation and self.order.sales_channel not in a.variation.sales_channels)
or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent) or a.item.unavailability_reason(has_voucher=True, subevent=a.subevent)
or self.order.sales_channel not in item.sales_channels or (
not item.all_sales_channels and
not item.limit_sales_channels.contains(self.order.sales_channel)
)
) )
if is_unavailable: if is_unavailable:
continue continue
+5 -2
View File
@@ -28,7 +28,8 @@ from django.db.models import Q
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.models import ( from pretix.base.models import (
AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation, Voucher, AbstractPosition, InvoiceAddress, Item, ItemAddOn, ItemVariation,
SalesChannel, Voucher,
) )
from pretix.base.models.event import Event, SubEvent from pretix.base.models.event import Event, SubEvent
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
@@ -164,12 +165,14 @@ def apply_discounts(event: Event, sales_channel: str,
:param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)`` :param positions: Tuple of the form ``(item_id, subevent_id, line_price_gross, is_addon_to, is_bundled, voucher_discount)``
:return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input :return: A list of ``(new_gross_price, discount)`` tuples in the same order as the input
""" """
if isinstance(sales_channel, SalesChannel):
sales_channel = sales_channel.identifier
new_prices = {} new_prices = {}
discount_qs = event.discounts.filter( discount_qs = event.discounts.filter(
Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()), Q(available_from__isnull=True) | Q(available_from__lte=time_machine_now()),
Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()), Q(available_until__isnull=True) | Q(available_until__gte=time_machine_now()),
sales_channels__contains=sales_channel, Q(all_sales_channels=True) | Q(limit_sales_channels__identifier=sales_channel),
active=True, active=True,
).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk') ).prefetch_related('condition_limit_products', 'benefit_limit_products').order_by('position', 'pk')
for discount in discount_qs: for discount in discount_qs:
-20
View File
@@ -2851,22 +2851,6 @@ Your {organizer} team""")) # noqa: W291
**primary_font_kwargs() **primary_font_kwargs()
), ),
}, },
'presale_css_file': {
'default': None,
'type': str
},
'presale_css_checksum': {
'default': None,
'type': str
},
'presale_widget_css_file': {
'default': None,
'type': str
},
'presale_widget_css_checksum': {
'default': None,
'type': str
},
'logo_image': { 'logo_image': {
'default': None, 'default': None,
'type': File, 'type': File,
@@ -3396,10 +3380,6 @@ Your {organizer} team""")) # noqa: W291
'type': str, 'type': str,
} }
} }
SETTINGS_AFFECTING_CSS = {
'primary_color', 'theme_color_success', 'theme_color_danger', 'primary_font',
'theme_color_background', 'theme_round_borders'
}
PERSON_NAME_TITLE_GROUPS = OrderedDict([ PERSON_NAME_TITLE_GROUPS = OrderedDict([
('english_common', (_('Most common English titles'), ( ('english_common', (_('Most common English titles'), (
'Mr', 'Mr',
+5 -2
View File
@@ -294,13 +294,16 @@ This signal is sent out when a notification is sent.
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.
""" """
register_sales_channels = django.dispatch.Signal() register_sales_channel_types = django.dispatch.Signal()
""" """
This signal is sent out to get all known sales channels types. Receivers should return an This signal is sent out to get all known sales channels types. Receivers should return an
instance of a subclass of ``pretix.base.channels.SalesChannel`` or a list of such instance of a subclass of ``pretix.base.channels.SalesChannelType`` or a list of such
instances. instances.
""" """
register_sales_channels = DeprecatedSignal() # TODO: remove me
register_data_exporters = EventPluginSignal() register_data_exporters = EventPluginSignal()
""" """
This signal is sent out to get all known data exporters. Receivers should return a This signal is sent out to get all known data exporters. Receivers should return a
@@ -0,0 +1,19 @@
{% load rich_text %}
{% load static %}
{% load i18n %}
{% if widget.wrap_label %}<label{% if widget.attrs.id %} for="{{ widget.attrs.id }}"{% endif %}>{% endif %}
{% include "django/forms/widgets/input.html" %}
{% if widget.wrap_label %}
{% if "." in widget.value.instance.type_instance.icon %}
<img class="fa-like-image" src="{% static widget.value.instance.type_instance.icon %}" alt="">
{% else %}
<span class="fa fa-fw fa-{{ widget.value.instance.type_instance.icon }} text-muted"></span>
{% endif %}
{% if widget.plugin_missing %}
<del>
{% endif %}
{{ widget.label }}{% if widget.plugin_missing %}</del>
<span class="fa fa-info-circle" data-toggle="tooltip" title="{% trans "This sales channel cannot be used properly since the respective plugin is not active for this event." %}"></span>
{% endif %}
</label>
{% endif %}
+17
View File
@@ -438,3 +438,20 @@ class ButtonGroupRadioSelect(forms.RadioSelect):
attrs['icon'] = self.option_icons[value] attrs['icon'] = self.option_icons[value]
opt = super().create_option(name, value, label, selected, index, subindex, attrs) opt = super().create_option(name, value, label, selected, index, subindex, attrs)
return opt return opt
class SalesChannelCheckboxSelectMultiple(forms.CheckboxSelectMultiple):
option_template_name = 'pretixbase/forms/widgets/checkbox_sales_channel_option.html'
def __init__(self, event, attrs=None, choices=()):
self.event = event
super().__init__(attrs, choices)
def create_option(
self, name, value, label, selected, index, subindex=None, attrs=None
):
plugin = value.instance.type_instance.required_event_plugin
return {
**super().create_option(name, value, label, selected, index, subindex, attrs),
"plugin_missing": plugin and plugin not in self.event.get_plugins(),
}
+7 -11
View File
@@ -30,11 +30,12 @@ from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField, SafeModelChoiceField, SafeModelMultipleChoiceField,
) )
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Gate from pretix.base.models import Gate
from pretix.base.models.checkin import Checkin, CheckinList from pretix.base.models.checkin import Checkin, CheckinList
from pretix.control.forms import ItemMultipleChoiceField from pretix.control.forms import (
ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple,
)
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
@@ -66,14 +67,9 @@ class CheckinListForm(forms.ModelForm):
kwargs.pop('locales', None) kwargs.pop('locales', None)
super().__init__(**kwargs) super().__init__(**kwargs)
self.fields['limit_products'].queryset = self.event.items.all() self.fields['limit_products'].queryset = self.event.items.all()
self.fields['auto_checkin_sales_channels'] = forms.MultipleChoiceField( self.fields['auto_checkin_sales_channels'].queryset = self.event.organizer.sales_channels.all()
label=self.fields['auto_checkin_sales_channels'].label, self.fields['auto_checkin_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(
help_text=self.fields['auto_checkin_sales_channels'].help_text, self.event, choices=self.fields['auto_checkin_sales_channels'].widget.choices
required=self.fields['auto_checkin_sales_channels'].required,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
) )
if not self.event.organizer.gates.exists(): if not self.event.organizer.gates.exists():
@@ -123,13 +119,13 @@ class CheckinListForm(forms.ModelForm):
'gates': forms.CheckboxSelectMultiple(attrs={ 'gates': forms.CheckboxSelectMultiple(attrs={
'class': 'scrolling-multiple-choice' 'class': 'scrolling-multiple-choice'
}), }),
'auto_checkin_sales_channels': forms.CheckboxSelectMultiple(),
'exit_all_at': NextTimeInput(attrs={'class': 'timepickerfield'}), 'exit_all_at': NextTimeInput(attrs={'class': 'timepickerfield'}),
} }
field_classes = { field_classes = {
'limit_products': ItemMultipleChoiceField, 'limit_products': ItemMultipleChoiceField,
'gates': SafeModelMultipleChoiceField, 'gates': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField, 'subevent': SafeModelChoiceField,
'auto_checkin_sales_channels': SafeModelMultipleChoiceField,
'exit_all_at': NextTimeField, 'exit_all_at': NextTimeField,
} }
+14 -12
View File
@@ -22,13 +22,16 @@
from decimal import Decimal from decimal import Decimal
from django import forms from django import forms
from django.utils.translation import gettext_lazy as _ from django_scopes.forms import SafeModelMultipleChoiceField
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channel_types
from pretix.base.forms import I18nModelForm from pretix.base.forms import I18nModelForm
from pretix.base.forms.widgets import SplitDateTimePickerWidget from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import Discount from pretix.base.models import Discount
from pretix.control.forms import ItemMultipleChoiceField, SplitDateTimeField from pretix.control.forms import (
ItemMultipleChoiceField, SalesChannelCheckboxSelectMultiple,
SplitDateTimeField,
)
class DiscountForm(I18nModelForm): class DiscountForm(I18nModelForm):
@@ -38,7 +41,8 @@ class DiscountForm(I18nModelForm):
fields = [ fields = [
'active', 'active',
'internal_name', 'internal_name',
'sales_channels', 'all_sales_channels',
'limit_sales_channels',
'available_from', 'available_from',
'available_until', 'available_until',
'subevent_mode', 'subevent_mode',
@@ -60,6 +64,7 @@ class DiscountForm(I18nModelForm):
'available_until': SplitDateTimeField, 'available_until': SplitDateTimeField,
'condition_limit_products': ItemMultipleChoiceField, 'condition_limit_products': ItemMultipleChoiceField,
'benefit_limit_products': ItemMultipleChoiceField, 'benefit_limit_products': ItemMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
} }
widgets = { widgets = {
'subevent_mode': forms.RadioSelect, 'subevent_mode': forms.RadioSelect,
@@ -83,15 +88,12 @@ class DiscountForm(I18nModelForm):
self.event = kwargs['event'] self.event = kwargs['event']
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['sales_channels'] = forms.MultipleChoiceField( self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.filter(
label=_('Sales channels'), type__in=[k for k, v in get_all_sales_channel_types().items() if v.discounts_supported]
required=True,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
if c.discounts_supported
),
widget=forms.CheckboxSelectMultiple,
) )
self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
'data-inverse-dependency': '<[name$=all_sales_channels]',
}, choices=self.fields['limit_sales_channels'].widget.choices)
self.fields['condition_limit_products'].queryset = self.event.items.all() self.fields['condition_limit_products'].queryset = self.event.items.all()
self.fields['benefit_limit_products'].queryset = self.event.items.all() self.fields['benefit_limit_products'].queryset = self.event.items.all()
self.fields['condition_min_count'].required = False self.fields['condition_min_count'].required = False
+20 -21
View File
@@ -44,9 +44,7 @@ from django.conf import settings
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.core.validators import MaxValueValidator from django.core.validators import MaxValueValidator
from django.db.models import Prefetch, Q, prefetch_related_objects from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import ( from django.forms import formset_factory, inlineformset_factory
CheckboxSelectMultiple, formset_factory, inlineformset_factory,
)
from django.urls import reverse from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.html import escape, format_html from django.utils.html import escape, format_html
@@ -54,12 +52,12 @@ from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name from django.utils.timezone import get_current_timezone_name
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from django_countries.fields import LazyTypedChoiceField from django_countries.fields import LazyTypedChoiceField
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import ( from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput, I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
) )
from pytz import common_timezones from pytz import common_timezones
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import ( from pretix.base.forms import (
I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator, SettingsForm, I18nMarkdownTextarea, I18nModelForm, PlaceholderValidator, SettingsForm,
) )
@@ -73,8 +71,8 @@ from pretix.base.settings import (
) )
from pretix.base.validators import multimail_validate from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, MultipleLanguagesWidget, SalesChannelCheckboxSelectMultiple, SlugWidget,
SplitDateTimePickerWidget, SplitDateTimeField, SplitDateTimePickerWidget,
) )
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
@@ -378,16 +376,10 @@ class EventUpdateForm(I18nModelForm):
required=False, required=False,
help_text=_('You need to configure the custom domain in the webserver beforehand.') help_text=_('You need to configure the custom domain in the webserver beforehand.')
) )
self.fields['sales_channels'] = forms.MultipleChoiceField( self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
label=self.fields['sales_channels'].label, self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
help_text=self.fields['sales_channels'].help_text, 'data-inverse-dependency': '<[name$=all_sales_channels]',
required=self.fields['sales_channels'].required, }, choices=self.fields['limit_sales_channels'].widget.choices)
initial=self.fields['sales_channels'].initial,
choices=(
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
)
def clean_domain(self): def clean_domain(self):
d = self.cleaned_data['domain'] d = self.cleaned_data['domain']
@@ -444,7 +436,8 @@ class EventUpdateForm(I18nModelForm):
'location', 'location',
'geo_lat', 'geo_lat',
'geo_lon', 'geo_lon',
'sales_channels' 'all_sales_channels',
'limit_sales_channels',
] ]
field_classes = { field_classes = {
'date_from': SplitDateTimeField, 'date_from': SplitDateTimeField,
@@ -452,6 +445,7 @@ class EventUpdateForm(I18nModelForm):
'date_admission': SplitDateTimeField, 'date_admission': SplitDateTimeField,
'presale_start': SplitDateTimeField, 'presale_start': SplitDateTimeField,
'presale_end': SplitDateTimeField, 'presale_end': SplitDateTimeField,
'limit_sales_channels': SafeModelMultipleChoiceField,
} }
widgets = { widgets = {
'date_from': SplitDateTimePickerWidget(), 'date_from': SplitDateTimePickerWidget(),
@@ -459,7 +453,6 @@ class EventUpdateForm(I18nModelForm):
'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}), 'date_admission': SplitDateTimePickerWidget(attrs={'data-date-default': '#id_date_from_0'}),
'presale_start': SplitDateTimePickerWidget(), 'presale_start': SplitDateTimePickerWidget(),
'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}), 'presale_end': SplitDateTimePickerWidget(attrs={'data-date-after': '#id_presale_start_0'}),
'sales_channels': CheckboxSelectMultiple(),
} }
@@ -915,7 +908,7 @@ class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
locale_names = dict(settings.LANGUAGES) locale_names = dict(settings.LANGUAGES)
self.fields['invoice_language'].choices = [('__user__', _('The user\'s language'))] + [(a, locale_names[a]) for a in event.settings.locales] self.fields['invoice_language'].choices = [('__user__', _('The user\'s language'))] + [(a, locale_names[a]) for a in event.settings.locales]
self.fields['invoice_generate_sales_channels'].choices = ( self.fields['invoice_generate_sales_channels'].choices = (
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values() (c.identifier, c.label) for c in event.organizer.sales_channels.all()
) )
self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15)) self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15))
@@ -961,7 +954,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
] ]
mail_sales_channel_placed_paid = forms.MultipleChoiceField( mail_sales_channel_placed_paid = forms.MultipleChoiceField(
choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()], choices=[],
label=_('Sales channels for checkout emails'), label=_('Sales channels for checkout emails'),
help_text=_('The order placed and paid emails will only be send to orders from these sales channels. ' help_text=_('The order placed and paid emails will only be send to orders from these sales channels. '
'The online shop must be enabled.'), 'The online shop must be enabled.'),
@@ -972,7 +965,7 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
) )
mail_sales_channel_download_reminder = forms.MultipleChoiceField( mail_sales_channel_download_reminder = forms.MultipleChoiceField(
choices=lambda: [(ident, sc.verbose_name) for ident, sc in get_all_sales_channels().items()], choices=[],
label=_('Sales channels'), label=_('Sales channels'),
help_text=_('This email will only be send to orders from these sales channels. The online shop must be enabled.'), help_text=_('This email will only be send to orders from these sales channels. The online shop must be enabled.'),
widget=forms.CheckboxSelectMultiple( widget=forms.CheckboxSelectMultiple(
@@ -1367,6 +1360,12 @@ class MailSettingsForm(FormPlaceholderMixin, SettingsForm):
self.fields['mail_html_renderer'].choices = [ self.fields['mail_html_renderer'].choices = [
(r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values() (r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values()
] ]
self.fields['mail_sales_channel_placed_paid'].choices = (
(c.identifier, c.label) for c in event.organizer.sales_channels.all()
)
self.fields['mail_sales_channel_download_reminder'].choices = (
(c.identifier, c.label) for c in event.organizer.sales_channels.all()
)
prefetch_related_objects([self.event.organizer], Prefetch('meta_properties')) prefetch_related_objects([self.event.organizer], Prefetch('meta_properties'))
self.event.meta_values_cached = self.event.meta_values.select_related('property').all() self.event.meta_values_cached = self.event.meta_values.select_related('property').all()
+7 -8
View File
@@ -50,15 +50,14 @@ from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext, gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelChoiceField from django_scopes.forms import SafeModelChoiceField
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms.widgets import ( from pretix.base.forms.widgets import (
DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget, DatePickerWidget, SplitDateTimePickerWidget, TimePickerWidget,
) )
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, SubEvent, OrderRefund, Organizer, Question, QuestionAnswer, Quota, SalesChannel,
SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher, SubEvent, SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
) )
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
@@ -579,9 +578,11 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
required=False, required=False,
label=_('Maximal sum of payments and refunds'), label=_('Maximal sum of payments and refunds'),
) )
sales_channel = forms.ChoiceField( sales_channel = SafeModelChoiceField(
label=_('Sales channel'), label=_('Sales channel'),
required=False, required=False,
queryset=SalesChannel.objects.none(),
to_field_name="identifier",
) )
has_checkin = forms.NullBooleanField( has_checkin = forms.NullBooleanField(
required=False, required=False,
@@ -604,9 +605,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
del self.fields['subevents_from'] del self.fields['subevents_from']
del self.fields['subevents_to'] del self.fields['subevents_to']
self.fields['sales_channel'].choices = [('', '')] + [ self.fields['sales_channel'].queryset = self.event.organizer.sales_channels.all()
(k, v.verbose_name) for k, v in get_all_sales_channels().items()
]
locale_names = dict(settings.LANGUAGES) locale_names = dict(settings.LANGUAGES)
self.fields['locale'].choices = [('', '')] + [(a, locale_names[a]) for a in self.event.settings.locales] self.fields['locale'].choices = [('', '')] + [(a, locale_names[a]) for a in self.event.settings.locales]
@@ -719,7 +718,7 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
if fdata.get('comment'): if fdata.get('comment'):
qs = qs.filter(comment__icontains=fdata.get('comment')) qs = qs.filter(comment__icontains=fdata.get('comment'))
if fdata.get('sales_channel'): if fdata.get('sales_channel'):
qs = qs.filter(sales_channel=fdata.get('sales_channel')) qs = qs.filter(sales_channel__identifier=fdata.get('sales_channel').identifier)
if fdata.get('total'): if fdata.get('total'):
qs = qs.filter(total=fdata.get('total')) qs = qs.filter(total=fdata.get('total'))
if fdata.get('email_known_to_work') is not None: if fdata.get('email_known_to_work') is not None:
+27 -28
View File
@@ -55,7 +55,6 @@ from django_scopes.forms import (
) )
from i18nfield.forms import I18nFormField, I18nTextarea from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm from pretix.base.forms import I18nFormSet, I18nMarkdownTextarea, I18nModelForm
from pretix.base.forms.widgets import DatePickerWidget from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import ( from pretix.base.models import (
@@ -64,7 +63,8 @@ from pretix.base.models import (
from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue from pretix.base.models.items import ItemAddOn, ItemBundle, ItemMetaValue
from pretix.base.signals import item_copy_data from pretix.base.signals import item_copy_data
from pretix.control.forms import ( from pretix.control.forms import (
ButtonGroupRadioSelect, ItemMultipleChoiceField, SizeValidationMixin, ButtonGroupRadioSelect, ItemMultipleChoiceField,
SalesChannelCheckboxSelectMultiple, SizeValidationMixin,
SplitDateTimeField, SplitDateTimePickerWidget, SplitDateTimeField, SplitDateTimePickerWidget,
) )
from pretix.control.forms.widgets import Select2, Select2ItemVarMulti from pretix.control.forms.widgets import Select2, Select2ItemVarMulti
@@ -413,7 +413,7 @@ class ItemCreateForm(I18nModelForm):
'checkin_text', 'checkin_text',
'free_price', 'free_price',
'original_price', 'original_price',
'sales_channels', 'all_sales_channels',
'issue_giftcard', 'issue_giftcard',
'require_approval', 'require_approval',
'allow_waitinglist', 'allow_waitinglist',
@@ -443,9 +443,6 @@ class ItemCreateForm(I18nModelForm):
if src.picture: if src.picture:
self.instance.picture.save(os.path.basename(src.picture.name), src.picture) self.instance.picture.save(os.path.basename(src.picture.name), src.picture)
else:
# Add to all sales channels by default
self.instance.sales_channels = list(get_all_sales_channels().keys())
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1 self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
if not self.instance.admission: if not self.instance.admission:
@@ -474,6 +471,8 @@ class ItemCreateForm(I18nModelForm):
}) })
if self.cleaned_data.get('copy_from'): if self.cleaned_data.get('copy_from'):
if not self.instance.all_sales_channels:
self.instance.limit_sales_channels.set(self.cleaned_data['copy_from'].limit_sales_channels.all())
self.instance.require_membership_types.set( self.instance.require_membership_types.set(
self.cleaned_data['copy_from'].require_membership_types.all() self.cleaned_data['copy_from'].require_membership_types.all()
) )
@@ -574,14 +573,10 @@ class ItemUpdateForm(I18nModelForm):
if self.event.tax_rules.exists(): if self.event.tax_rules.exists():
self.fields['tax_rule'].required = True self.fields['tax_rule'].required = True
self.fields['description'].widget.attrs['rows'] = '4' self.fields['description'].widget.attrs['rows'] = '4'
self.fields['sales_channels'] = forms.MultipleChoiceField( self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
label=_('Sales channels'), self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
required=False, 'data-inverse-dependency': '<[name$=all_sales_channels]',
choices=( }, choices=self.fields['limit_sales_channels'].widget.choices)
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
widget=forms.CheckboxSelectMultiple
)
change_decimal_field(self.fields['default_price'], self.event.currency) change_decimal_field(self.fields['default_price'], self.event.currency)
self.fields['available_from_mode'].widget = ButtonGroupRadioSelect( self.fields['available_from_mode'].widget = ButtonGroupRadioSelect(
@@ -744,6 +739,14 @@ class ItemUpdateForm(I18nModelForm):
_("The start of validity must be before the end of validity.") _("The start of validity must be before the end of validity.")
) )
if d.get('validity_mode') == Item.VALIDITY_MODE_DYNAMIC:
if not any(d.get(f'validity_dynamic_duration_{k}') for k in ('months', 'days', 'hours', 'minutes')):
self.add_error(
'validity_dynamic_duration_months',
_("You have selected dynamic validity but have not entered a time period. This would render "
"the tickets unusable.")
)
Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard')) Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard'))
return d return d
@@ -764,7 +767,8 @@ class ItemUpdateForm(I18nModelForm):
'name', 'name',
'internal_name', 'internal_name',
'active', 'active',
'sales_channels', 'all_sales_channels',
'limit_sales_channels',
'admission', 'admission',
'personalized', 'personalized',
'description', 'description',
@@ -821,6 +825,7 @@ class ItemUpdateForm(I18nModelForm):
'hidden_if_item_available': SafeModelChoiceField, 'hidden_if_item_available': SafeModelChoiceField,
'grant_membership_type': SafeModelChoiceField, 'grant_membership_type': SafeModelChoiceField,
'require_membership_types': SafeModelMultipleChoiceField, 'require_membership_types': SafeModelMultipleChoiceField,
'limit_sales_channels': SafeModelMultipleChoiceField,
} }
widgets = { widgets = {
'available_from': SplitDateTimePickerWidget(), 'available_from': SplitDateTimePickerWidget(),
@@ -892,18 +897,10 @@ class ItemVariationForm(I18nModelForm):
qs = kwargs.pop('membership_types') qs = kwargs.pop('membership_types')
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
change_decimal_field(self.fields['default_price'], self.event.currency) change_decimal_field(self.fields['default_price'], self.event.currency)
self.fields['sales_channels'] = forms.MultipleChoiceField( self.fields['limit_sales_channels'].queryset = self.event.organizer.sales_channels.all()
label=_('Sales channels'), self.fields['limit_sales_channels'].widget = SalesChannelCheckboxSelectMultiple(self.event, attrs={
required=False, 'data-inverse-dependency': '<[name$=all_sales_channels]',
choices=( }, choices=self.fields['limit_sales_channels'].widget.choices)
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
),
help_text=_('The sales channel selection for the product as a whole takes precedence, so if a sales channel is '
'selected here but not on product level, the variation will not be available.'),
widget=forms.CheckboxSelectMultiple
)
if not self.instance.pk:
self.initial.setdefault('sales_channels', list(get_all_sales_channels().keys()))
self.fields['description'].widget.attrs['rows'] = 3 self.fields['description'].widget.attrs['rows'] = 3
if qs: if qs:
@@ -975,12 +972,14 @@ class ItemVariationForm(I18nModelForm):
'available_from_mode', 'available_from_mode',
'available_until', 'available_until',
'available_until_mode', 'available_until_mode',
'sales_channels', 'all_sales_channels',
'limit_sales_channels',
'hide_without_voucher', 'hide_without_voucher',
] ]
field_classes = { field_classes = {
'available_from': SplitDateTimeField, 'available_from': SplitDateTimeField,
'available_until': SplitDateTimeField, 'available_until': SplitDateTimeField,
'limit_sales_channels': SafeModelMultipleChoiceField,
} }
widgets = { widgets = {
'available_from': SplitDateTimePickerWidget(), 'available_from': SplitDateTimePickerWidget(),
+40 -1
View File
@@ -50,6 +50,7 @@ from django_scopes.forms import SafeModelChoiceField
from i18nfield.forms import ( from i18nfield.forms import (
I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput, I18nForm, I18nFormField, I18nFormSetMixin, I18nTextInput,
) )
from i18nfield.strings import LazyI18nString
from phonenumber_field.formfields import PhoneNumberField from phonenumber_field.formfields import PhoneNumberField
from pytz import common_timezones from pytz import common_timezones
@@ -68,7 +69,8 @@ from pretix.base.forms.widgets import (
) )
from pretix.base.models import ( from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance, Customer, Device, EventMetaProperty, Gate, GiftCard, GiftCardAcceptance,
Membership, MembershipType, OrderPosition, Organizer, ReusableMedium, Team, Membership, MembershipType, OrderPosition, Organizer, ReusableMedium,
SalesChannel, Team,
) )
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink from pretix.base.models.organizer import OrganizerFooterLink
@@ -1090,3 +1092,40 @@ class GiftCardAcceptanceInviteForm(forms.Form):
if self.organizer.gift_card_acceptor_acceptance.filter(acceptor=acceptor).exists(): if self.organizer.gift_card_acceptor_acceptance.filter(acceptor=acceptor).exists():
raise ValidationError(_('The selected organizer has already been invited.')) raise ValidationError(_('The selected organizer has already been invited.'))
return acceptor return acceptor
class SalesChannelForm(I18nModelForm):
class Meta:
model = SalesChannel
fields = ['label', 'identifier']
widgets = {
'default': forms.TextInput(),
}
def __init__(self, *args, **kwargs):
self.type = kwargs.pop("type")
super().__init__(*args, **kwargs)
if not self.type.multiple_allowed or (self.instance and self.instance.pk):
self.fields["identifier"].initial = self.type.identifier
self.fields["identifier"].disabled = True
self.fields["label"].initial = LazyI18nString.from_gettext(self.type.verbose_name)
def clean(self):
d = super().clean()
if self.instance.pk:
d["identifier"] = self.instance.identifier
elif self.type.multiple_allowed:
d["identifier"] = self.type.identifier + "." + d["identifier"]
else:
d["identifier"] = self.type.identifier
if not self.instance.pk:
# self.event is actually the organizer, sorry I18nModelForm!
if self.event.sales_channels.filter(identifier=d["identifier"]).exists():
raise ValidationError(
_("A sales channel with the same identifier already exists.")
)
return d
+70 -4
View File
@@ -21,16 +21,82 @@
# #
from bootstrap3.renderers import FieldRenderer, InlineFieldRenderer from bootstrap3.renderers import FieldRenderer, InlineFieldRenderer
from bootstrap3.text import text_value from bootstrap3.text import text_value
from django.forms import CheckboxInput, CheckboxSelectMultiple, RadioSelect
from django.forms.utils import flatatt from django.forms.utils import flatatt
from django.utils.html import format_html from django.utils.html import format_html
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import pgettext from django.utils.translation import pgettext
from i18nfield.forms import I18nFormField
from pretix.base.forms.renderers import PretixFieldRenderer
class ControlFieldRenderer(PretixFieldRenderer): def render_label(content, label_for=None, label_class=None, label_title='', label_id='', optional=False):
pass """
Render a label with content
"""
attrs = {}
if label_for:
attrs['for'] = label_for
if label_class:
attrs['class'] = label_class
if label_title:
attrs['title'] = label_title
if label_id:
attrs['id'] = label_id
if text_value(content) == '&#160;':
# Empty label, e.g. checkbox
attrs.setdefault('class', '')
attrs['class'] += ' label-empty'
builder = '<{tag}{attrs}>{content}{opt}</{tag}>'
return format_html(
builder,
tag='label',
attrs=mark_safe(flatatt(attrs)) if attrs else '',
opt=mark_safe('<br><span class="optional">{}</span>'.format(pgettext('form', 'Optional'))) if optional else '',
content=text_value(content),
)
class ControlFieldRenderer(FieldRenderer):
def __init__(self, *args, **kwargs):
kwargs['layout'] = 'horizontal'
super().__init__(*args, **kwargs)
self.is_group_widget = isinstance(self.widget, (CheckboxSelectMultiple, RadioSelect, )) or (self.is_multi_widget and len(self.widget.widgets) > 1)
def add_label(self, html):
label = self.get_label()
if hasattr(self.field.field, '_required'):
# e.g. payment settings forms where a field is only required if the payment provider is active
required = self.field.field._required
elif isinstance(self.field.field, I18nFormField):
required = self.field.field.one_required
else:
required = self.field.field.required
if self.is_group_widget:
label_for = ""
label_id = "legend-{}".format(self.field.html_name)
else:
label_for = self.field.id_for_label
label_id = ""
html = render_label(
label,
label_for=label_for,
label_class=self.get_label_class(),
label_id=label_id,
optional=not required and not isinstance(self.widget, CheckboxInput)
) + html
return html
def wrap_label_and_field(self, html):
if self.is_group_widget:
attrs = ' role="group" aria-labelledby="legend-{}"'.format(self.field.html_name)
else:
attrs = ''
return '<div class="{klass}"{attrs}>{html}</div>'.format(klass=self.get_form_group_class(), html=html, attrs=attrs)
class ControlFieldWithVisibilityRenderer(ControlFieldRenderer): class ControlFieldWithVisibilityRenderer(ControlFieldRenderer):
+2 -2
View File
@@ -366,9 +366,9 @@ class VoucherBulkForm(VoucherForm):
raise ValidationError(_('CSV input contains an unknown field with the header "{header}".').format(header=unknown_fields[0])) raise ValidationError(_('CSV input contains an unknown field with the header "{header}".').format(header=unknown_fields[0]))
for i, row in enumerate(reader): for i, row in enumerate(reader):
try: try:
EmailValidator()(row['email']) EmailValidator()(row['email'].strip())
except ValidationError as err: except ValidationError as err:
raise ValidationError(_('{value} is not a valid email address.').format(value=row['email'])) from err raise ValidationError(_('{value} is not a valid email address.').format(value=row['email'].strip())) from err
try: try:
res.append(self.Recipient( res.append(self.Recipient(
name=row.get('name', ''), name=row.get('name', ''),
+3
View File
@@ -357,6 +357,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.membershiptype.created': _('The membership type has been created.'), 'pretix.membershiptype.created': _('The membership type has been created.'),
'pretix.membershiptype.changed': _('The membership type has been changed.'), 'pretix.membershiptype.changed': _('The membership type has been changed.'),
'pretix.membershiptype.deleted': _('The membership type has been deleted.'), 'pretix.membershiptype.deleted': _('The membership type has been deleted.'),
'pretix.saleschannel.created': _('The sales channel has been created.'),
'pretix.saleschannel.changed': _('The sales channel has been changed.'),
'pretix.saleschannel.deleted': _('The sales channel has been deleted.'),
'pretix.customer.created': _('The account has been created.'), 'pretix.customer.created': _('The account has been created.'),
'pretix.customer.changed': _('The account has been changed.'), 'pretix.customer.changed': _('The account has been changed.'),
'pretix.customer.membership.created': _('A membership for this account has been added.'), 'pretix.customer.membership.created': _('A membership for this account has been added.'),
+7
View File
@@ -502,6 +502,13 @@ def get_organizer_navigation(request):
}), }),
'active': url.url_name == 'organizer.settings.mail', 'active': url.url_name == 'organizer.settings.mail',
}, },
{
'label': _('Sales channels'),
'url': reverse('control:organizer.channels', kwargs={
'organizer': request.organizer.slug
}),
'active': url.url_name.startswith('organizer.channel'),
},
{ {
'label': _('Webhooks'), 'label': _('Webhooks'),
'url': reverse('control:organizer.webhooks', kwargs={ 'url': reverse('control:organizer.webhooks', kwargs={
@@ -1,6 +1,7 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load static %}
{% load urlreplace %} {% load urlreplace %}
{% block title %}{% trans "Check-in lists" %}{% endblock %} {% block title %}{% trans "Check-in lists" %}{% endblock %}
{% block inside %} {% block inside %}
@@ -137,9 +138,14 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
<td> <td>
{% for channel in cl.auto_checkin_sales_channels %} {% for channel in cl.auto_checkin_sales_channels.all %}
<span class="fa fa-{{ channel.icon }} text-muted" {% if "." in channel.icon %}
data-toggle="tooltip" title="{% trans channel.verbose_name %}"></span> <img src="{% static channel.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ channel.label }}">
{% else %}
<span class="fa fa-{{ channel.icon }} text-muted"
data-toggle="tooltip" title="{{ channel.label }}"></span>
{% endif %}
{% endfor %} {% endfor %}
</td> </td>
<td> <td>
@@ -1,5 +1,6 @@
{% extends "pretixcontrol/event/settings_base.html" %} {% extends "pretixcontrol/event/settings_base.html" %}
{% load i18n %} {% load i18n %}
{% load static %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% block inside %} {% block inside %}
<h1>{% trans "Payment settings" %}</h1> <h1>{% trans "Payment settings" %}</h1>
@@ -30,8 +31,13 @@
</td> </td>
<td class="iconcol"> <td class="iconcol">
{% for channel in provider.sales_channels %} {% for channel in provider.sales_channels %}
<span class="fa fa-{{ channel.icon }} text-muted" {% if "." in channel.icon %}
data-toggle="tooltip" title="{% trans channel.verbose_name %}"></span> <img src="{% static channel.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ channel.label }}">
{% else %}
<span class="fa fa-{{ channel.icon }} text-muted"
data-toggle="tooltip" title="{{ channel.label }}"></span>
{% endif %}
{% endfor %} {% endfor %}
</td> </td>
<td class="text-right flip"> <td class="text-right flip">
@@ -32,7 +32,8 @@
{% bootstrap_field sform.contact_mail layout="control" %} {% bootstrap_field sform.contact_mail layout="control" %}
{% bootstrap_field sform.imprint_url layout="control" %} {% bootstrap_field sform.imprint_url layout="control" %}
{% bootstrap_field form.is_public layout="control" %} {% bootstrap_field form.is_public layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %} {% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
{% if meta_forms %} {% if meta_forms %}
<div class="form-group metadata-group"> <div class="form-group metadata-group">
@@ -1,6 +1,7 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load formset_tags %} {% load formset_tags %}
{% load static %}
{% load getitem %} {% load getitem %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" id="item_variations"> <div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" id="item_variations">
{{ formset.management_form }} {{ formset.management_form }}
@@ -40,9 +41,14 @@
title="{% trans "Require a valid membership" %}"></span> title="{% trans "Require a valid membership" %}"></span>
</div> </div>
<div class="col-md-2 col-xs-6"> <div class="col-md-2 col-xs-6">
{% for k, c in sales_channels.items %} {% for c in sales_channels %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden" {% if "." in c.icon %}
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span> <img src="{% static c.icon %}" class="fa-like-image variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<div class="col-md-1 col-xs-6 text-right flip variation-price"> <div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -97,7 +103,8 @@
{% endif %} {% endif %}
{% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %} {% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %}
{% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %} {% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %}
{% bootstrap_field form.sales_channels layout="control" %} {% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
{% bootstrap_field form.hide_without_voucher layout="control" %} {% bootstrap_field form.hide_without_voucher layout="control" %}
{% bootstrap_field form.require_approval layout="control" %} {% bootstrap_field form.require_approval layout="control" %}
{% if form.require_membership %} {% if form.require_membership %}
@@ -148,9 +155,14 @@
title="{% trans "Require a valid membership" %}"></span> title="{% trans "Require a valid membership" %}"></span>
</div> </div>
<div class="col-md-2 col-xs-6"> <div class="col-md-2 col-xs-6">
{% for k, c in sales_channels.items %} {% for c in sales_channels %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden" {% if "." in c.icon %}
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span> <img src="{% static c.icon %}" class="fa-like-image variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ c.id }} variation-icon-hidden"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% endfor %} {% endfor %}
</div> </div>
<div class="col-md-1 col-xs-6 text-right flip variation-price"> <div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -197,7 +209,8 @@
{% bootstrap_field formset.empty_form.available_from visibility_field=formset.empty_form.available_from_mode layout="control_with_visibility" %} {% bootstrap_field formset.empty_form.available_from visibility_field=formset.empty_form.available_from_mode layout="control_with_visibility" %}
{% bootstrap_field formset.empty_form.available_until visibility_field=formset.empty_form.available_until_mode layout="control_with_visibility" %} {% bootstrap_field formset.empty_form.available_until visibility_field=formset.empty_form.available_until_mode layout="control_with_visibility" %}
{% bootstrap_field formset.empty_form.available_until layout="control" %} {% bootstrap_field formset.empty_form.available_until layout="control" %}
{% bootstrap_field formset.empty_form.sales_channels layout="control" %} {% bootstrap_field formset.empty_form.all_sales_channels layout="control" %}
{% bootstrap_field formset.empty_form.limit_sales_channels layout="control" %}
{% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %} {% bootstrap_field formset.empty_form.hide_without_voucher layout="control" %}
{% bootstrap_field formset.empty_form.require_approval layout="control" %} {% bootstrap_field formset.empty_form.require_approval layout="control" %}
{% if formset.empty_form.require_membership %} {% if formset.empty_form.require_membership %}
@@ -152,9 +152,8 @@
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Availability" %}</legend> <legend>{% trans "Availability" %}</legend>
{% bootstrap_field form.all_sales_channels layout="control" horizontal_field_class="col-md-7" %}
{% bootstrap_field form.limit_sales_channels layout="control" horizontal_field_class="col-md-7" %}
{% bootstrap_field form.sales_channels layout="control" horizontal_field_class="col-md-7" %}
{% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %} {% bootstrap_field form.available_from visibility_field=form.available_from_mode layout="control_with_visibility" %}
{% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %} {% bootstrap_field form.available_until visibility_field=form.available_until_mode layout="control_with_visibility" %}
{% bootstrap_field form.max_per_order layout="control" horizontal_field_class="col-md-7" %} {% bootstrap_field form.max_per_order layout="control" horizontal_field_class="col-md-7" %}
@@ -32,7 +32,6 @@
<tr> <tr>
<th>{% trans "Product categories" %}</th> <th>{% trans "Product categories" %}</th>
<th class="action-col-2"></th> <th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr> </tr>
</thead> </thead>
<tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}"> <tbody data-dnd-url="{% url "control:event.items.categories.reorder" organizer=request.event.organizer.slug event=request.event.slug %}">
@@ -41,18 +40,16 @@
<td> <td>
<strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong> <strong><a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}">{{ c.internal_name|default:c.name }}</a></strong>
</td> </td>
<td>
<button formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip"> <td class="text-right flip">
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> <button title="{% trans "Move up" %}" formaction="{% url "control:event.items.categories.up" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button title="{% trans "Move down" %}" formaction="{% url "control:event.items.categories.down" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container" title="{% trans "Click and drag this button to reorder. Double click to show buttons for reordering." %}"></span>
<a title="{% trans "Edit" %}" href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ c.id }}" <a href="{% url "control:event.items.categories.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ c.id }}"
class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip"> class="btn btn-sm btn-default" title="{% trans "Clone" %}" data-toggle="tooltip">
<span class="fa fa-copy"></span> <span class="fa fa-copy"></span>
</a> </a>
<a href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> <a title="{% trans "Delete" %}" href="{% url "control:event.items.categories.delete" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -15,7 +15,8 @@
{% bootstrap_field form.internal_name layout="control" %} {% bootstrap_field form.internal_name layout="control" %}
{% bootstrap_field form.available_from layout="control" %} {% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.available_until layout="control" %} {% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %} {% bootstrap_field form.all_sales_channels layout="control" %}
{% bootstrap_field form.limit_sales_channels layout="control" %}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>{% trans "Condition" context "discount" %}</legend> <legend>{% trans "Condition" context "discount" %}</legend>
@@ -1,5 +1,6 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load static %}
{% block title %}{% trans "Automatic discounts" %}{% endblock %} {% block title %}{% trans "Automatic discounts" %}{% endblock %}
{% block inside %} {% block inside %}
<h1>{% trans "Automatic discounts" %}</h1> <h1>{% trans "Automatic discounts" %}</h1>
@@ -56,8 +57,7 @@
<th>{% trans "Internal name" %}</th> <th>{% trans "Internal name" %}</th>
<th></th> <th></th>
<th></th> <th></th>
<th>{% trans "Products" %}</th> <th colspan="2">{% trans "Products" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th> <th class="action-col-2"></th>
</tr> </tr>
</thead> </thead>
@@ -79,10 +79,15 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% for k, c in sales_channels.items %} {% for c in sales_channels %}
{% if k in d.sales_channels %} {% if d.all_sales_channels or c in d.limit_sales_channels.all %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted" {% if "." in c.icon %}
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span> <img src="{% static c.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% else %} {% else %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -102,9 +107,10 @@
{% endif %} {% endif %}
{% endif %} {% endif %}
</td> </td>
<td> <td {% if d.benefit_same_products %}colspan="2"{% endif %}>
{% if not d.benefit_same_products %}{% trans "Condition:" %}{% endif %}
{% if d.condition_all_products %} {% if d.condition_all_products %}
<em>{% trans "All" %}</em> <ul><li><em>{% trans "All" %}</em></li></ul>
{% else %} {% else %}
<ul> <ul>
{% for item in d.condition_limit_products.all %} {% for item in d.condition_limit_products.all %}
@@ -115,18 +121,28 @@
</ul> </ul>
{% endif %} {% endif %}
</td> </td>
<td> {% if not d.benefit_same_products %}
<td>
{% trans "Applies to:" %}
<ul>
{% for item in d.benefit_limit_products.all %}
<li>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=item.id %}">{{ item }}</a>
</li>
{% endfor %}
</ul>
</td>
{% endif %}
<td class="text-right flip">
<button formaction="{% url "control:event.items.discounts.up" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}" <button formaction="{% url "control:event.items.discounts.up" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-up" class="btn btn-default btn-sm sortable-up" title="{% trans "Move up" %}"
{% if forloop.counter0 == 0 and not page_obj.has_previous %} {% if forloop.counter0 == 0 and not page_obj.has_previous %}
disabled{% endif %}><i class="fa fa-arrow-up"></i></button> disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:event.items.discounts.down" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}" <button formaction="{% url "control:event.items.discounts.down" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm sortable-down" class="btn btn-default btn-sm sortable-down" title="{% trans "Move down" %}"
{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}> {% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}>
<i class="fa fa-arrow-down"></i></button> <i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span> <span class="dnd-container" title="{% trans "Click and drag this button to reorder. Double click to show buttons for reordering." %}"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}" <a href="{% url "control:event.items.discounts.edit" organizer=request.event.organizer.slug event=request.event.slug discount=d.id %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ d.id }}" <a href="{% url "control:event.items.discounts.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ d.id }}"
@@ -1,13 +1,16 @@
{% extends "pretixcontrol/items/base.html" %} {% extends "pretixcontrol/items/base.html" %}
{% load i18n %} {% load i18n %}
{% load money %}
{% load static %}
{% block title %}{% trans "Products" %}{% endblock %} {% block title %}{% trans "Products" %}{% endblock %}
{% block inside %} {% block inside %}
{% blocktrans asvar s_taxes %}taxes{% endblocktrans %}
<h1>{% trans "Products" %}</h1> <h1>{% trans "Products" %}</h1>
<p> <p>
{% blocktrans trimmed %} {% blocktrans trimmed %}
Below, you find a list of all available products. You can click on a product name to inspect and change Below, you find a list of all available products. You can click on a product name to inspect and change
product details. You can also use the buttons on the right to change the order of products within a product details. You can also use the buttons on the right to change the order of products or move
give category. products to a different category.
{% endblocktrans %} {% endblocktrans %}
</p> </p>
{% if items|length == 0 %} {% if items|length == 0 %}
@@ -29,7 +32,7 @@
<form method="post"> <form method="post">
{% csrf_token %} {% csrf_token %}
<div class="table-responsive"> <div class="table-responsive">
<table class="table table-condensed table-hover"> <table class="table table-condensed table-hover table-items">
<thead> <thead>
<tr> <tr>
<th>{% trans "Product name" %}</th> <th>{% trans "Product name" %}</th>
@@ -37,16 +40,24 @@
<th class="iconcol"></th> <th class="iconcol"></th>
<th class="iconcol"></th> <th class="iconcol"></th>
<th class="iconcol"></th> <th class="iconcol"></th>
<th>{% trans "Category" %}</th> <th class="text-right flip">{% trans "Default price" %}</th>
<th class="action-col-2"><span class="sr-only">Move</span></th>
<th class="action-col-2"><span class="sr-only">Edit</span></th> <th class="action-col-2"><span class="sr-only">Edit</span></th>
</tr> </tr>
</thead> </thead>
{% regroup items by category as cat_list %}
{% for c in cat_list %} {% for c, items in cat_list %}
<tbody data-dnd-url="{% url "control:event.items.reorder" organizer=request.event.organizer.slug event=request.event.slug %}"> {% if c %}
{% for i in c.list %} <tbody>
{% if forloop.counter0 == 0 and i.category %}<tr class="sortable-disabled"><th colspan="8" scope="colgroup" class="text-muted">{{ i.category }}</th></tr>{% endif %} <tr class="sortable-disabled"><th colspan="9" scope="colgroup" class="text-muted">
{{ c.internal_name|default:c.name }}{% if c.category_type != "normal" %} <span class="font-normal">({{ c.get_category_type_display }})</span>{% endif %}
<a href="{% url "control:event.items.categories.edit" organizer=request.event.organizer.slug event=request.event.slug category=c.id %}" title="{% trans "Edit" %}"><span class="fa fa-edit fa-fw"></span></a>
</th></tr>
</tbody>
{% endif %}
<tbody data-dnd-url="{% url "control:event.items.reorder" organizer=request.event.organizer.slug event=request.event.slug category=c.id|default:0 %}"
data-dnd-group="items">
{% for i in items %}
{% if forloop.counter0 == 0 and i.category %}{% endif %}
<tr data-dnd-id="{{ i.id }}" {% if not i.active %}class="row-muted"{% endif %}> <tr data-dnd-id="{{ i.id }}" {% if not i.active %}class="row-muted"{% endif %}>
<td><strong> <td><strong>
{% if not i.active %}<strike>{% endif %} {% if not i.active %}<strike>{% endif %}
@@ -56,10 +67,15 @@
<br> <br>
<small class="text-muted"> <small class="text-muted">
#{{ i.pk }} #{{ i.pk }}
{% for k, c in sales_channels.items %} {% for c in sales_channels %}
{% if k in i.sales_channels %} {% if i.all_sales_channels or c in i.limit_sales_channels.all %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted" {% if "." in c.icon %}
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span> <img src="{% static c.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ c.label }}">
{% else %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted"
data-toggle="tooltip" title="{{ c.label }}"></span>
{% endif %}
{% else %} {% else %}
{% endif %} {% endif %}
{% endfor %} {% endfor %}
@@ -92,15 +108,15 @@
</td> </td>
<td> <td>
{% if i.var_count %} {% if i.var_count %}
<span class="fa fa-th-large fa-fw text-muted" data-toggle="tooltip" title="{% trans "Product with variations" %}"></span> <span class="fa fa-bars fa-fw text-muted" data-toggle="tooltip" title="{% trans "Product with variations" %}"></span>
{% endif %} {% endif %}
</td> </td>
<td> <td>
{% if i.category.is_addon %} {% if i.category.is_addon %}
<span class="fa fa-puzzle-piece fa-fw text-muted" data-toggle="tooltip" <span class="fa fa-plus-square fa-fw text-muted" data-toggle="tooltip"
title="{% trans "Only available as an add-on product" %}"></span> title="{% trans "Only available as an add-on product" %}"></span>
{% elif i.require_bundling %} {% elif i.require_bundling %}
<span class="fa fa-puzzle-piece fa-fw text-muted" data-toggle="tooltip" <span class="fa fa-plus-square fa-fw text-muted" data-toggle="tooltip"
title="{% trans "Only available as part of a bundle" %}"></span> title="{% trans "Only available as part of a bundle" %}"></span>
{% elif i.hide_without_voucher %} {% elif i.hide_without_voucher %}
<span class="fa fa-tags fa-fw text-muted" data-toggle="tooltip" <span class="fa fa-tags fa-fw text-muted" data-toggle="tooltip"
@@ -110,16 +126,29 @@
title="{% trans "Can only be bought using a voucher" %}"></span> title="{% trans "Can only be bought using a voucher" %}"></span>
{% endif %} {% endif %}
</td> </td>
<td>{% if i.category %}{{ i.category.name }}{% endif %}</td> <td class="text-right flip">
<td> {% if i.free_price %}
<button formaction="{% url "control:event.items.up" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button> <span class="fa fa-edit fa-fw text-muted" data-toggle="tooltip" title="{% trans "Free price input" %}">
<button formaction="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button> </span>
<span class="dnd-container"></span> {% endif %}
{{ i.default_price|money:request.event.currency }}
{% if i.original_price %}<strike class="text-muted">{{ i.original_price|money:request.event.currency }}</strike>{% endif %}
{% if i.tax_rule and i.default_price %}
<br/>
<small class="text-muted">
{% blocktrans trimmed with rate=i.tax_rule.rate|floatformat:-2 taxname=i.tax_rule.name|default:s_taxes %}
incl. {{ rate }}% {{ taxname }}
{% endblocktrans %}
</small>
{% endif %}
</td> </td>
<td class="text-right flip col-actions"> <td class="text-right flip col-actions">
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a> <button title="{% trans "Move up" %}" formaction="{% url "control:event.items.up" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button title="{% trans "Move down" %}" formaction="{% url "control:event.items.down" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container" title="{% trans "Click and drag this button to reorder. Double click to show buttons for reordering." %}"></span>
<a href="{% url "control:event.item" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-default btn-sm" title="{% trans "Edit" %}"><i class="fa fa-edit"></i></a>
<a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ i.id }}" class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a> <a href="{% url "control:event.items.add" organizer=request.event.organizer.slug event=request.event.slug %}?copy_from={{ i.id }}" class="btn btn-default btn-sm" title="{% trans "Clone" %}" data-toggle="tooltip"><i class="fa fa-copy"></i></a>
<a href="{% url "control:event.items.delete" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-danger btn-sm"><i class="fa fa-trash"></i></a> <a href="{% url "control:event.items.delete" organizer=request.event.organizer.slug event=request.event.slug item=i.id %}" class="btn btn-danger btn-sm" title="{% trans "Delete" %}"><i class="fa fa-trash"></i></a>
</td> </td>
</tr> </tr>
{% endfor %} {% endfor %}
@@ -185,9 +185,9 @@
<dt>{% trans "Cancellation date" %}</dt> <dt>{% trans "Cancellation date" %}</dt>
<dd>{{ order.cancellation_date|date:"SHORT_DATETIME_FORMAT" }}</dd> <dd>{{ order.cancellation_date|date:"SHORT_DATETIME_FORMAT" }}</dd>
{% endif %} {% endif %}
{% if sales_channel %} {% if order.sales_channel %}
<dt>{% trans "Sales channel" %}</dt> <dt>{% trans "Sales channel" %}</dt>
<dd>{{ sales_channel.verbose_name }}</dd> <dd>{{ order.sales_channel.label }}</dd>
{% endif %} {% endif %}
<dt>{% trans "Order locale" %}</dt> <dt>{% trans "Order locale" %}</dt>
<dd> <dd>
@@ -4,6 +4,7 @@
{% load urlreplace %} {% load urlreplace %}
{% load money %} {% load money %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load static %}
{% block title %}{% trans "Orders" %}{% endblock %} {% block title %}{% trans "Orders" %}{% endblock %}
{% block content %} {% block content %}
<h1>{% trans "Orders" %}</h1> <h1>{% trans "Orders" %}</h1>
@@ -201,8 +202,13 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<span class="fa fa-fw fa-{{ o.sales_channel_obj.icon }} text-muted" {% if "." in o.sales_channel.icon %}
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span> <img src="{% static o.sales_channel.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ o.sales_channel.label }}">
{% else %}
<span class="fa fa-fw fa-{{ o.sales_channel.icon }} text-muted"
data-toggle="tooltip" title="{{ o.sales_channel.label }}"></span>
{% endif %}
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }} {{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
</td> </td>
<td class="text-right flip"> <td class="text-right flip">
@@ -0,0 +1,27 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load formset_tags %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Add sales channel" %}</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form layout="control" %}
{% bootstrap_field form.label layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_identifier">{% trans "Channel type" %}</label>
<div class="col-md-9">
<input type="text" value="{{ type.verbose_name }}" class="form-control" disabled>
</div>
</div>
{% bootstrap_field form.identifier addon_before=identifier_prefix layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,26 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load static %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Add sales channel" %}</h1>
<div class="list-group large-link-group">
{% for t in types %}
<a class="list-group-item" href="?type={{ t.identifier }}">
<h4>
{% if "." in t.icon %}
<img class="fa-like-image" src="{% static t.icon %}" alt="">
{% else %}
<span class="fa fa-fw fa-{{ t.icon }} text-muted"></span>
{% endif %}
{{ t.verbose_name }}
</h4>
{% if t.description %}
<p>
{{ t.description }}
</p>
{% endif %}
</a>
{% endfor %}
</div>
{% endblock %}
@@ -0,0 +1,33 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Delete sales channel:" %} {{ channel.label }}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
{% if is_allowed %}
<p>
{% blocktrans trimmed %}
Are you sure you want to delete this sales channel?
{% endblocktrans %}
{% else %}
<div class="alert alert-danger">
{% blocktrans trimmed %}
This sales channel cannot be deleted since it has already been used to sell orders or because it is
a core element of the system.
{% endblocktrans %}
</div>
{% endif %}
<div class="form-group submit-group">
<a href="{% url "control:organizer.channels" organizer=request.organizer.slug %}"
class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
{% if is_allowed %}
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
{% endif %}
</div>
</form>
{% endblock %}
@@ -0,0 +1,27 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load formset_tags %}
{% load bootstrap3 %}
{% block inner %}
<h1>{% trans "Sales channel:" %} {{ channel.label }}</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form_errors form layout="control" %}
{% bootstrap_field form.label layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label" for="id_identifier">{% trans "Channel type" %}</label>
<div class="col-md-9">
<input type="text" value="{{ type.verbose_name }}" class="form-control" disabled>
</div>
</div>
{% bootstrap_field form.identifier layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,64 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load static %}
{% block inner %}
<h1>{% trans "Sales channels" %}</h1>
<p>
{% blocktrans trimmed %}
On this page, you can manage the different channels your tickets can be sold through. This is useful
to unlock new revenue streams or to separate revenue between different sources for reporting purchases.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.channel.add" organizer=request.organizer.slug %}" class="btn btn-default">
<span class="fa fa-plus"></span>
{% trans "Add a new channel" %}
</a>
<form method="post">
{% csrf_token %}
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Channel" %}</th>
<th>{% trans "Identifier" %}</th>
<th>{% trans "Channel type" %}</th>
<th class="action-col-2"></th>
<th class="action-col-2"></th>
</tr>
</thead>
<tbody data-dnd-url="{% url "control:organizer.channels.reorder" organizer=request.organizer.slug %}">
{% for c in channels %}
<tr data-dnd-id="{{ c.pk }}">
<td><strong>
<a href="{% url "control:organizer.channel.edit" organizer=request.organizer.slug channel=c.identifier %}">
{{ c.label }}
</a>
</strong></td>
<td>
<code>{{ c.identifier }}</code>
</td>
<td>
{% if "." in c.type_instance.icon %}
<img class="fa-like-image" src="{% static c.icon %}" alt="">
{% else %}
<span class="fa fa-fw fa-{{ c.type_instance.icon }} text-muted"></span>
{% endif %}
{{ c.type_instance.verbose_name }}
</td>
<td>
<button formaction="{% url "control:organizer.channel.up" organizer=request.organizer.slug channel=c.identifier %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:organizer.channel.down" organizer=request.organizer.slug channel=c.identifier %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span>
</td>
<td class="text-right flip">
<a href="{% url "control:organizer.channel.edit" organizer=request.organizer.slug channel=c.identifier %}"
class="btn btn-default btn-sm"><i class="fa fa-edit"></i></a>
<a href="{% url "control:organizer.channel.delete" organizer=request.organizer.slug channel=c.identifier %}"
class="btn btn-danger btn-sm {% if c.type_instance.default_created %}disabled{% endif %}"><i class="fa fa-trash"></i></a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</form>
{% endblock %}
@@ -2,6 +2,7 @@
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load money %} {% load money %}
{% load static %}
{% block title %} {% block title %}
{% blocktrans trimmed with id=customer.identifier %} {% blocktrans trimmed with id=customer.identifier %}
Customer #{{ id }} Customer #{{ id }}
@@ -225,8 +226,13 @@
{{ o.event }} {{ o.event }}
</td> </td>
<td> <td>
<span class="fa fa-{{ o.sales_channel_obj.icon }} text-muted" {% if "." in o.sales_channel.icon %}
data-toggle="tooltip" title="{% trans o.sales_channel_obj.verbose_name %}"></span> <img src="{% static o.sales_channel.icon %}" class="fa-like-image"
data-toggle="tooltip" title="{{ o.sales_channel.label }}">
{% else %}
<span class="fa fa-{{ o.sales_channel.icon }} text-muted"
data-toggle="tooltip" title="{{ o.sales_channel.label }}"></span>
{% endif %}
{{ o.datetime|date:"SHORT_DATETIME_FORMAT" }} {{ o.datetime|date:"SHORT_DATETIME_FORMAT" }}
{% if o.customer_id != customer.pk %} {% if o.customer_id != customer.pk %}
<span class="fa fa-link text-muted" <span class="fa fa-link text-muted"
@@ -51,9 +51,9 @@
{% endif %} {% endif %}
</td> </td>
<td> <td>
<button formaction="{% url "control:organizer.property.up" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button> <button title="{% trans "Move up" %}" formaction="{% url "control:organizer.property.up" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-up"{% if forloop.counter0 == 0 and not page_obj.has_previous %} disabled{% endif %}><i class="fa fa-arrow-up"></i></button>
<button formaction="{% url "control:organizer.property.down" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button> <button title="{% trans "Move down" %}" formaction="{% url "control:organizer.property.down" organizer=request.organizer.slug property=p.id %}" class="btn btn-default btn-sm sortable-down"{% if forloop.revcounter0 == 0 and not page_obj.has_next %} disabled{% endif %}><i class="fa fa-arrow-down"></i></button>
<span class="dnd-container"></span> <span class="dnd-container" title="{% trans "Click and drag this button to reorder. Double click to show buttons for reordering." %}"></span>
</td> </td>
<td class="text-right flip"> <td class="text-right flip">
<a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}" <a href="{% url "control:organizer.property.edit" organizer=request.organizer.slug property=p.id %}"
+14 -1
View File
@@ -136,6 +136,19 @@ urlpatterns = [
name='organizer.property.down'), name='organizer.property.down'),
re_path(r'^organizer/(?P<organizer>[^/]+)/property/reorder$', organizer.reorder_meta_properties, re_path(r'^organizer/(?P<organizer>[^/]+)/property/reorder$', organizer.reorder_meta_properties,
name='organizer.properties.reorder'), name='organizer.properties.reorder'),
re_path(r'^organizer/(?P<organizer>[^/]+)/channels$', organizer.ChannelListView.as_view(), name='organizer.channels'),
re_path(r'^organizer/(?P<organizer>[^/]+)/channel/add$', organizer.ChannelCreateView.as_view(),
name='organizer.channel.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/channel/(?P<channel>[^/]+)/edit$', organizer.ChannelUpdateView.as_view(),
name='organizer.channel.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/channel/(?P<channel>[^/]+)/delete$', organizer.ChannelDeleteView.as_view(),
name='organizer.channel.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/channel/(?P<channel>[^/]+)/up$', organizer.channel_move_up,
name='organizer.channel.up'),
re_path(r'^organizer/(?P<organizer>[^/]+)/channel/(?P<channel>[^/]+)/down$', organizer.channel_move_down,
name='organizer.channel.down'),
re_path(r'^organizer/(?P<organizer>[^/]+)/channel/reorder$', organizer.reorder_channels,
name='organizer.channels.reorder'),
re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'), re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptypes$', organizer.MembershipTypeListView.as_view(), name='organizer.membershiptypes'),
re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(), re_path(r'^organizer/(?P<organizer>[^/]+)/membershiptype/add$', organizer.MembershipTypeCreateView.as_view(),
name='organizer.membershiptype.add'), name='organizer.membershiptype.add'),
@@ -293,7 +306,7 @@ urlpatterns = [
re_path(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'), re_path(r'^items/(?P<item>\d+)/$', item.ItemUpdateGeneral.as_view(), name='event.item'),
re_path(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'), re_path(r'^items/(?P<item>\d+)/up$', item.item_move_up, name='event.items.up'),
re_path(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'), re_path(r'^items/(?P<item>\d+)/down$', item.item_move_down, name='event.items.down'),
re_path(r'^items/reorder$', item.reorder_items, name='event.items.reorder'), re_path(r'^items/reorder/(?P<category>\d+)/$', item.reorder_items, name='event.items.reorder'),
re_path(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'), re_path(r'^items/(?P<item>\d+)/delete$', item.ItemDelete.as_view(), name='event.items.delete'),
re_path(r'^items/typeahead/meta/$', typeahead.item_meta_values, name='event.items.meta.typeahead'), re_path(r'^items/typeahead/meta/$', typeahead.item_meta_values, name='event.items.meta.typeahead'),
re_path(r'^items/select2$', typeahead.items_select2, name='event.items.select2'), re_path(r'^items/select2$', typeahead.items_select2, name='event.items.select2'),
+3 -4
View File
@@ -49,7 +49,6 @@ from django.views.generic import FormView, ListView, TemplateView
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.api.views.checkin import _redeem_process from pretix.api.views.checkin import _redeem_process
from pretix.base.channels import get_all_sales_channels
from pretix.base.models import Checkin, Order, OrderPosition from pretix.base.models import Checkin, Order, OrderPosition
from pretix.base.models.checkin import CheckinList from pretix.base.models.checkin import CheckinList
from pretix.base.services.checkin import ( from pretix.base.services.checkin import (
@@ -296,7 +295,9 @@ class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, ListView):
ordering = ('subevent__date_from', 'name', 'pk') ordering = ('subevent__date_from', 'name', 'pk')
def get_queryset(self): def get_queryset(self):
qs = self.request.event.checkin_lists.select_related('subevent').prefetch_related("limit_products") qs = self.request.event.checkin_lists.select_related('subevent').prefetch_related(
"limit_products", "auto_checkin_sales_channels"
)
if self.filter_form.is_valid(): if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs) qs = self.filter_form.filter_qs(qs)
@@ -305,12 +306,10 @@ class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, 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)
clists = list(ctx['checkinlists']) clists = list(ctx['checkinlists'])
sales_channels = get_all_sales_channels()
for cl in clists: for cl in clists:
if cl.subevent: if cl.subevent:
cl.subevent.event = self.request.event # re-use same event object to make sure settings are cached cl.subevent.event = self.request.event # re-use same event object to make sure settings are cached
cl.auto_checkin_sales_channels = [sales_channels[channel] for channel in cl.auto_checkin_sales_channels]
ctx['checkinlists'] = clists ctx['checkinlists'] = clists
ctx['can_change_organizer_settings'] = self.request.user.has_organizer_permission( ctx['can_change_organizer_settings'] = self.request.user.has_organizer_permission(
+5 -3
View File
@@ -43,7 +43,6 @@ from pretix.control.permissions import (
) )
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from ...base.channels import get_all_sales_channels
from ...helpers.compat import CompatDeleteView from ...helpers.compat import CompatDeleteView
from . import CreateView, PaginationMixin, UpdateView from . import CreateView, PaginationMixin, UpdateView
@@ -190,11 +189,14 @@ class DiscountList(PaginationMixin, ListView):
template_name = 'pretixcontrol/items/discounts.html' template_name = 'pretixcontrol/items/discounts.html'
def get_queryset(self): def get_queryset(self):
return self.request.event.discounts.prefetch_related('condition_limit_products') return self.request.event.discounts.prefetch_related('condition_limit_products', 'limit_sales_channels')
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['sales_channels'] = get_all_sales_channels() ctx['sales_channels'] = [
c for c in self.request.organizer.sales_channels.all()
if c.type_instance.discounts_supported
]
return ctx return ctx
+10 -17
View File
@@ -71,7 +71,6 @@ from django.views.generic.detail import SingleObjectMixin
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from i18nfield.utils import I18nJSONEncoder from i18nfield.utils import I18nJSONEncoder
from pretix.base.channels import get_all_sales_channels
from pretix.base.email import get_available_placeholders from pretix.base.email import get_available_placeholders
from pretix.base.forms import PlaceholderValidator from pretix.base.forms import PlaceholderValidator
from pretix.base.models import Event, LogEntry, Order, TaxRule, Voucher from pretix.base.models import Event, LogEntry, Order, TaxRule, Voucher
@@ -94,13 +93,12 @@ from pretix.control.views.user import RecentAuthenticationRequiredMixin
from pretix.helpers.database import rolledback_transaction from pretix.helpers.database import rolledback_transaction
from pretix.multidomain.urlreverse import build_absolute_uri, get_event_domain from pretix.multidomain.urlreverse import build_absolute_uri, get_event_domain
from pretix.plugins.stripe.payment import StripeSettingsHolder from pretix.plugins.stripe.payment import StripeSettingsHolder
from pretix.presale.style import regenerate_css
from ...base.i18n import language from ...base.i18n import language
from ...base.models.items import ( from ...base.models.items import (
Item, ItemCategory, ItemMetaProperty, Question, Quota, Item, ItemCategory, ItemMetaProperty, Question, Quota,
) )
from ...base.settings import SETTINGS_AFFECTING_CSS, LazyI18nStringList from ...base.settings import LazyI18nStringList
from ...helpers.compat import CompatDeleteView from ...helpers.compat import CompatDeleteView
from ...helpers.format import format_map from ...helpers.format import format_map
from ..logdisplay import OVERVIEW_BANLIST from ..logdisplay import OVERVIEW_BANLIST
@@ -202,19 +200,17 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
def form_valid(self, form): def form_valid(self, form):
self._save_decoupled(self.sform) self._save_decoupled(self.sform)
self.sform.save() self.sform.save()
self.object.cache.clear()
self.save_meta() self.save_meta()
self.save_item_meta_property_formset(self.object) self.save_item_meta_property_formset(self.object)
self.save_confirm_texts_formset(self.object) self.save_confirm_texts_formset(self.object)
self.save_footer_links_formset(self.object) self.save_footer_links_formset(self.object)
change_css = False
if self.sform.has_changed() or self.confirm_texts_formset.has_changed(): if self.sform.has_changed() or self.confirm_texts_formset.has_changed():
data = {k: self.request.event.settings.get(k) for k in self.sform.changed_data} data = {k: self.request.event.settings.get(k) for k in self.sform.changed_data}
if self.confirm_texts_formset.has_changed(): if self.confirm_texts_formset.has_changed():
data.update(confirm_texts=self.confirm_texts_formset.cleaned_data) data.update(confirm_texts=self.confirm_texts_formset.cleaned_data)
self.request.event.log_action('pretix.event.settings', user=self.request.user, data=data) self.request.event.log_action('pretix.event.settings', user=self.request.user, data=data)
if any(p in self.sform.changed_data for p in SETTINGS_AFFECTING_CSS):
change_css = True
if self.footer_links_formset.has_changed(): if self.footer_links_formset.has_changed():
self.request.event.log_action('pretix.event.footerlinks.changed', user=self.request.user, data={ self.request.event.log_action('pretix.event.footerlinks.changed', user=self.request.user, data={
'data': self.footer_links_formset.cleaned_data 'data': self.footer_links_formset.cleaned_data
@@ -228,13 +224,7 @@ class EventUpdate(DecoupleMixin, EventSettingsViewMixin, EventPermissionRequired
}) })
tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk}) tickets.invalidate_cache.apply_async(kwargs={'event': self.request.event.pk})
if change_css: messages.success(self.request, _('Your changes have been saved.'))
regenerate_css.apply_async(args=(self.request.event.pk,))
messages.success(self.request, _('Your changes have been saved. Please note that it can '
'take a short period of time until your changes become '
'active.'))
else:
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form) return super().form_valid(form)
def get_success_url(self) -> str: def get_success_url(self) -> str:
@@ -568,7 +558,7 @@ class PaymentSettings(EventSettingsViewMixin, EventSettingsFormView):
key=lambda s: s.verbose_name key=lambda s: s.verbose_name
) )
sales_channels = get_all_sales_channels() sales_channels = {s.identifier: s for s in self.request.organizer.sales_channels.all()}
for p in context['providers']: for p in context['providers']:
p.show_enabled = p.is_enabled p.show_enabled = p.is_enabled
p.sales_channels = [sales_channels[channel] for channel in p.settings.get('_restrict_to_sales_channels', as_type=list, default=['web'])] p.sales_channels = [sales_channels[channel] for channel in p.settings.get('_restrict_to_sales_channels', as_type=list, default=['web'])]
@@ -795,8 +785,11 @@ class MailSettingsRendererPreview(MailSettingsPreview):
renderers = request.event.get_html_mail_renderers() renderers = request.event.get_html_mail_renderers()
if request.GET.get('renderer') in renderers: if request.GET.get('renderer') in renderers:
with rolledback_transaction(): with rolledback_transaction():
order = request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(), order = request.event.orders.create(
expires=now(), code="PREVIEW", total=119) status=Order.STATUS_PENDING, datetime=now(),
expires=now(), code="PREVIEW", total=119,
sales_channel=request.organizer.sales_channels.get(identifier="web")
)
item = request.event.items.create(name=gettext("Sample product"), default_price=42.23, item = request.event.items.create(name=gettext("Sample product"), default_price=42.23,
description=gettext("Sample product description")) description=gettext("Sample product description"))
order.positions.create(item=item, attendee_name_parts={'_legacy': gettext("John Doe")}, order.positions.create(item=item, attendee_name_parts={'_legacy': gettext("John Doe")},
@@ -1477,7 +1470,7 @@ class QuickSetupView(FormView):
admission=True, admission=True,
personalized=True, personalized=True,
position=i, position=i,
sales_channels=list(get_all_sales_channels().keys()) all_sales_channels=True,
) )
item.log_action('pretix.event.item.added', user=self.request.user, data=dict(f.cleaned_data)) item.log_action('pretix.event.item.added', user=self.request.user, data=dict(f.cleaned_data))
if f.cleaned_data['quota'] or not form.cleaned_data['total_quota']: if f.cleaned_data['quota'] or not form.cleaned_data['total_quota']:
+18 -16
View File
@@ -35,6 +35,7 @@
import json import json
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from itertools import groupby
from json.decoder import JSONDecodeError from json.decoder import JSONDecodeError
from django.contrib import messages from django.contrib import messages
@@ -83,7 +84,6 @@ from pretix.control.permissions import (
from pretix.control.signals import item_forms, item_formsets from pretix.control.signals import item_forms, item_formsets
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from ...base.channels import get_all_sales_channels
from ...helpers.compat import CompatDeleteView from ...helpers.compat import CompatDeleteView
from . import ChartContainingView, CreateView, PaginationMixin, UpdateView from . import ChartContainingView, CreateView, PaginationMixin, UpdateView
@@ -105,14 +105,16 @@ class ItemList(ListView):
event=self.request.event event=self.request.event
).annotate( ).annotate(
var_count=Count('variations') var_count=Count('variations')
).prefetch_related("category").order_by( ).prefetch_related("category", "limit_sales_channels").order_by(
F('category__position').asc(nulls_first=True), F('category__position').asc(nulls_first=True),
'category', 'position' 'category', 'position'
) )
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['sales_channels'] = get_all_sales_channels() ctx['sales_channels'] = self.request.organizer.sales_channels.all()
items_by_category = {cat: list(items) for cat, items in groupby(ctx['items'], lambda item: item.category)}
ctx['cat_list'] = [(cat, items_by_category.get(cat, [])) for cat in [None, *self.request.event.categories.all()]]
return ctx return ctx
@@ -169,7 +171,7 @@ def item_move_down(request, organizer, event, item):
@transaction.atomic @transaction.atomic
@event_permission_required("can_change_items") @event_permission_required("can_change_items")
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def reorder_items(request, organizer, event): def reorder_items(request, organizer, event, category):
try: try:
ids = json.loads(request.body.decode('utf-8'))['ids'] ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError): except (JSONDecodeError, KeyError, ValueError):
@@ -180,23 +182,21 @@ def reorder_items(request, organizer, event):
if len(input_items) != len(ids): if len(input_items) != len(ids):
raise Http404(_("Some of the provided object ids are invalid.")) raise Http404(_("Some of the provided object ids are invalid."))
item_categories = {i.category_id for i in input_items} if int(category):
if len(item_categories) > 1: target_category = request.event.categories.get(id=category)
raise Http404(_("You cannot reorder items spanning different categories.")) else:
target_category = None
# get first and only category
item_category = next(iter(item_categories))
if len(input_items) != request.event.items.filter(category=item_category).count():
raise Http404(_("Not all objects have been selected."))
for i in input_items: for i in input_items:
pos = ids.index(str(i.pk)) pos = ids.index(str(i.pk))
if pos != i.position: # Save unneccessary UPDATE queries if pos != i.position or target_category != i.category: # Save unneccessary UPDATE queries
i.position = pos i.position = pos
i.save(update_fields=['position']) i.category = target_category
i.save(update_fields=['position', 'category_id'])
i.log_action( i.log_action(
'pretix.event.item.reordered', user=request.user, data={ 'pretix.event.item.reordered', user=request.user, data={
'position': i, 'position': i,
'category': target_category and target_category.pk,
} }
) )
@@ -1502,7 +1502,7 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
"Your participants won't be able to buy the bundle unless you remove this " "Your participants won't be able to buy the bundle unless you remove this "
"item from it.")) "item from it."))
ctx['sales_channels'] = get_all_sales_channels() ctx['sales_channels'] = self.request.organizer.sales_channels.all()
return ctx return ctx
@cached_property @cached_property
@@ -1514,7 +1514,9 @@ class ItemUpdateGeneral(ItemDetailMixin, EventPermissionRequiredMixin, MetaDataE
can_order=True, can_delete=True, extra=0 can_order=True, can_delete=True, extra=0
)( )(
self.request.POST if self.request.method == "POST" else None, self.request.POST if self.request.method == "POST" else None,
queryset=ItemVariation.objects.filter(item=self.get_object()).prefetch_related('meta_values', 'require_membership_types'), queryset=ItemVariation.objects.filter(item=self.get_object()).prefetch_related(
'meta_values', 'limit_sales_channels', 'require_membership_types'
),
event=self.request.event, prefix="variations" event=self.request.event, prefix="variations"
)), )),
('addons', inlineformset_factory( ('addons', inlineformset_factory(
+1 -5
View File
@@ -71,7 +71,6 @@ from django.views.generic import (
) )
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.channels import get_all_sales_channels
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.exporter import MultiSheetListExporter from pretix.base.exporter import MultiSheetListExporter
@@ -375,7 +374,7 @@ class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin,
def get_queryset(self): def get_queryset(self):
qs = Order.objects.filter( qs = Order.objects.filter(
event=self.request.event event=self.request.event
).select_related('invoice_address') ).select_related('invoice_address').prefetch_related("sales_channel")
if self.filter_form.is_valid(): if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs) qs = self.filter_form.filter_qs(qs)
@@ -420,7 +419,6 @@ class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin,
) )
} }
scs = get_all_sales_channels()
for o in ctx['orders']: for o in ctx['orders']:
if o.pk not in annotated: if o.pk not in annotated:
continue continue
@@ -433,7 +431,6 @@ class OrderList(OrderSearchMixin, EventPermissionRequiredMixin, PaginationMixin,
o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request'] o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request']
o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum'] o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum']
o.icnt = annotated.get(o.pk)['icnt'] o.icnt = annotated.get(o.pk)['icnt']
o.sales_channel_obj = scs[o.sales_channel]
if ctx['page_obj'].paginator.count < 1000: if ctx['page_obj'].paginator.count < 1000:
# Performance safeguard: Only count positions if the data set is small # Performance safeguard: Only count positions if the data set is small
@@ -520,7 +517,6 @@ class OrderDetail(OrderView):
ctx['display_locale'] = dict(settings.LANGUAGES)[self.object.locale or self.request.event.settings.locale] ctx['display_locale'] = dict(settings.LANGUAGES)[self.object.locale or self.request.event.settings.locale]
ctx['overpaid'] = self.order.pending_sum * -1 ctx['overpaid'] = self.order.pending_sum * -1
ctx['sales_channel'] = get_all_sales_channels().get(self.order.sales_channel)
ctx['download_buttons'] = self.download_buttons ctx['download_buttons'] = self.download_buttons
ctx['payment_refund_sum'] = self.order.payment_refund_sum ctx['payment_refund_sum'] = self.order.payment_refund_sum
ctx['pending_sum'] = self.order.pending_sum ctx['pending_sum'] = self.order.pending_sum
+248 -21
View File
@@ -56,7 +56,7 @@ from django.forms import DecimalField
from django.http import ( from django.http import (
Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, Http404, HttpResponse, HttpResponseBadRequest, JsonResponse,
) )
from django.shortcuts import get_object_or_404, redirect from django.shortcuts import get_object_or_404, redirect, render
from django.urls import reverse from django.urls import reverse
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
@@ -71,7 +71,7 @@ from django.views.generic import (
from pretix.api.models import ApiCall, WebHook from pretix.api.models import ApiCall, WebHook
from pretix.api.webhooks import manually_retry_all_calls from pretix.api.webhooks import manually_retry_all_calls
from pretix.base.auth import get_auth_backends from pretix.base.auth import get_auth_backends
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channel_types
from pretix.base.exporter import ( from pretix.base.exporter import (
MultiSheetListExporter, OrganizerLevelExportMixin, MultiSheetListExporter, OrganizerLevelExportMixin,
) )
@@ -87,11 +87,10 @@ from pretix.base.models.giftcards import (
GiftCardAcceptance, GiftCardTransaction, gen_giftcard_secret, GiftCardAcceptance, GiftCardTransaction, gen_giftcard_secret,
) )
from pretix.base.models.orders import CancellationRequest from pretix.base.models.orders import CancellationRequest
from pretix.base.models.organizer import TeamAPIToken from pretix.base.models.organizer import SalesChannel, TeamAPIToken
from pretix.base.payment import PaymentException from pretix.base.payment import PaymentException
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 from pretix.base.services.mail import SendMailException, mail
from pretix.base.settings import SETTINGS_AFFECTING_CSS
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
@@ -108,8 +107,8 @@ from pretix.control.forms.organizer import (
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm, MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm, OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm, OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm, ReusableMediumUpdateForm, SalesChannelForm, SSOClientForm, SSOProviderForm,
WebHookForm, TeamForm, WebHookForm,
) )
from pretix.control.forms.rrule import RRuleForm from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST from pretix.control.logdisplay import OVERVIEW_BANLIST
@@ -127,7 +126,6 @@ from pretix.helpers.format import format_map
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
from pretix.presale.forms.customer import TokenGenerator from pretix.presale.forms.customer import TokenGenerator
from pretix.presale.style import regenerate_organizer_css
class OrganizerList(PaginationMixin, ListView): class OrganizerList(PaginationMixin, ListView):
@@ -466,7 +464,7 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
def form_valid(self, form): def form_valid(self, form):
self.sform.save() self.sform.save()
self.save_footer_links_formset(self.object) self.save_footer_links_formset(self.object)
change_css = False self.object.cache.clear()
if self.sform.has_changed(): if self.sform.has_changed():
self.request.organizer.log_action( self.request.organizer.log_action(
'pretix.organizer.settings', 'pretix.organizer.settings',
@@ -478,8 +476,6 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
for k in self.sform.changed_data for k in self.sform.changed_data
} }
) )
if any(p in self.sform.changed_data for p in SETTINGS_AFFECTING_CSS):
change_css = True
if self.footer_links_formset.has_changed(): if self.footer_links_formset.has_changed():
self.request.organizer.log_action('pretix.organizer.footerlinks.changed', user=self.request.user, data={ self.request.organizer.log_action('pretix.organizer.footerlinks.changed', user=self.request.user, data={
'data': self.footer_links_formset.cleaned_data 'data': self.footer_links_formset.cleaned_data
@@ -491,13 +487,7 @@ class OrganizerUpdate(OrganizerPermissionRequiredMixin, UpdateView):
data={k: form.cleaned_data.get(k) for k in form.changed_data} data={k: form.cleaned_data.get(k) for k in form.changed_data}
) )
if change_css: messages.success(self.request, _('Your changes have been saved.'))
regenerate_organizer_css.apply_async(args=(self.request.organizer.pk,))
messages.success(self.request, _('Your changes have been saved. Please note that it can '
'take a short period of time until your changes become '
'active.'))
else:
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form) return super().form_valid(form)
def get_form_kwargs(self): def get_form_kwargs(self):
@@ -2216,7 +2206,7 @@ def meta_property_move_down(request, organizer, property):
@transaction.atomic @transaction.atomic
@organizer_permission_required("can_change_items") @organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"]) @require_http_methods(["POST"])
def reorder_meta_properties(request, organizer): def reorder_meta_properties(request, organizer):
try: try:
@@ -2653,7 +2643,7 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
q |= Q(email__iexact=self.customer.email) q |= Q(email__iexact=self.customer.email)
qs = Order.objects.filter( qs = Order.objects.filter(
q q
).select_related('event').order_by('-datetime', 'pk') ).select_related('event').prefetch_related('sales_channel').order_by('-datetime', 'pk')
return qs return qs
@cached_property @cached_property
@@ -2730,7 +2720,6 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
) )
} }
scs = get_all_sales_channels()
for o in ctx['orders']: for o in ctx['orders']:
if o.pk not in annotated: if o.pk not in annotated:
continue continue
@@ -2743,7 +2732,6 @@ class CustomerDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMi
o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request'] o.has_cancellation_request = annotated.get(o.pk)['has_cancellation_request']
o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum'] o.computed_payment_refund_sum = annotated.get(o.pk)['computed_payment_refund_sum']
o.icnt = annotated.get(o.pk)['icnt'] o.icnt = annotated.get(o.pk)['icnt']
o.sales_channel_obj = scs[o.sales_channel]
ctx["lifetime_spending"] = ( ctx["lifetime_spending"] = (
self.get_queryset() self.get_queryset()
@@ -3050,3 +3038,242 @@ class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequ
'organizer': self.request.organizer.slug, 'organizer': self.request.organizer.slug,
'pk': self.object.pk, 'pk': self.object.pk,
}) })
class ChannelListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ListView):
model = SalesChannel
template_name = 'pretixcontrol/organizers/channels.html'
permission = 'can_change_organizer_settings'
context_object_name = 'channels'
def get_queryset(self):
return self.request.organizer.sales_channels.all()
class ChannelEditorMixin:
form_class = SalesChannelForm
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
'event': self.request.organizer,
}
class ChannelCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ChannelEditorMixin, CreateView):
model = SalesChannel
permission = 'can_change_organizer_settings'
template_name = 'pretixcontrol/organizers/channel_add.html'
def get_object(self, queryset=None):
return SalesChannel()
@property
def allowed_types(self):
existing_types = set(self.request.organizer.sales_channels.values_list("type", flat=True))
return {
k: t for k, t in get_all_sales_channel_types().items()
if t.multiple_allowed or t.identifier not in existing_types
}
@cached_property
def selected_type(self):
try:
return self.allowed_types[self.request.GET.get("type")]
except KeyError:
return None
def post(self, request, *args, **kwargs):
if not self.selected_type:
return render(request, "pretixcontrol/organizers/channel_add_choice.html", {
"types": self.allowed_types.values()
})
return super().post(request, *args, **kwargs)
def get(self, request, *args, **kwargs):
if not self.selected_type:
return render(request, "pretixcontrol/organizers/channel_add_choice.html", {
"types": self.allowed_types.values()
})
return super().get(request, *args, **kwargs)
def get_success_url(self):
return reverse('control:organizer.channels', kwargs={
'organizer': self.request.organizer.slug,
})
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["type"] = self.selected_type
if self.selected_type.multiple_allowed:
ctx["identifier_prefix"] = self.selected_type.identifier + "."
return ctx
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
"type": self.selected_type,
}
def form_valid(self, form):
messages.success(self.request, _('The sales channel has been created.'))
form.instance.organizer = self.request.organizer
form.instance.type = self.selected_type.identifier
form.instance.position = (self.request.organizer.sales_channels.aggregate(m=Max("position"))["m"] or 0) + 1
ret = super().form_valid(form)
form.instance.log_action('pretix.saleschannel.created', user=self.request.user, data={
k: getattr(self.object, k) for k in form.changed_data
})
return ret
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class ChannelUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, ChannelEditorMixin, UpdateView):
model = SalesChannel
permission = 'can_change_organizer_settings'
context_object_name = 'channel'
template_name = 'pretixcontrol/organizers/channel_edit.html'
def get_object(self, queryset=None):
return get_object_or_404(SalesChannel, organizer=self.request.organizer, identifier=self.kwargs.get('channel'))
def get_success_url(self):
return reverse('control:organizer.channels', kwargs={
'organizer': self.request.organizer.slug,
})
@cached_property
def type(self):
return get_all_sales_channel_types()[self.object.type]
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx["type"] = self.type
return ctx
def get_form_kwargs(self):
return {
**super().get_form_kwargs(),
"type": self.type,
}
def form_valid(self, form):
if form.has_changed() or self.formset.has_changed():
self.object.log_action('pretix.saleschannel.changed', user=self.request.user, data={
k: getattr(self.object, k)
for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return super().form_valid(form)
def form_invalid(self, form):
messages.error(self.request, _('Your changes could not be saved.'))
return super().form_invalid(form)
class ChannelDeleteView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CompatDeleteView):
model = SalesChannel
template_name = 'pretixcontrol/organizers/channel_delete.html'
permission = 'can_change_organizer_settings'
context_object_name = 'channel'
def get_object(self, queryset=None):
return get_object_or_404(SalesChannel, organizer=self.request.organizer, identifier=self.kwargs.get('channel'))
def get_success_url(self):
return reverse('control:organizer.channels', kwargs={
'organizer': self.request.organizer.slug,
})
def get_context_data(self, **kwargs):
ctx = super().get_context_data()
ctx["is_allowed"] = self.get_object().allow_delete
return ctx
@transaction.atomic
def delete(self, request, *args, **kwargs):
self.object = self.get_object()
success_url = self.get_success_url()
if not self.object.allow_delete():
messages.error(self.request, _('This channel can not be deleted.'))
return redirect(success_url)
try:
self.object.log_action('pretix.saleschannel.deleted', user=self.request.user)
self.object.delete()
messages.success(request, _('The selected sales channel has been deleted.'))
except ProtectedError:
messages.error(self.request, _('The channel could not be deleted as some constraints (e.g. data created by '
'plug-ins) did not allow it.'))
return redirect(success_url)
def channel_move(request, channel, up=True):
channel = get_object_or_404(request.organizer.sales_channels, identifier=channel)
channels = list(request.organizer.sales_channels.order_by("position"))
index = channels.index(channel)
if index != 0 and up:
channels[index - 1], channels[index] = channels[index], channels[index - 1]
elif index != len(channels) - 1 and not up:
channels[index + 1], channels[index] = channels[index], channels[index + 1]
for i, prop in enumerate(channels):
if prop.position != i:
prop.position = i
prop.save()
prop.log_action(
'pretix.saleschannel.reordered', user=request.user, data={
'position': i,
}
)
messages.success(request, _('The order of sales channels has been updated.'))
@organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"])
def channel_move_up(request, organizer, channel):
channel_move(request, channel, up=True)
return redirect('control:organizer.channels',
organizer=request.organizer.slug)
@organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"])
def channel_move_down(request, organizer, channel):
channel_move(request, channel, up=False)
return redirect('control:organizer.channels',
organizer=request.organizer.slug)
@transaction.atomic
@organizer_permission_required("can_change_organizer_settings")
@require_http_methods(["POST"])
def reorder_channels(request, organizer):
try:
ids = json.loads(request.body.decode('utf-8'))['ids']
except (JSONDecodeError, KeyError, ValueError):
return HttpResponseBadRequest("expected JSON: {ids:[]}")
input_channels = list(request.organizer.sales_channels.filter(id__in=[i for i in ids if i.isdigit()]))
if len(input_channels) != len(ids):
raise Http404(_("Some of the provided object ids are invalid."))
if len(input_channels) != request.organizer.sales_channels.count():
raise Http404(_("Not all objects have been selected."))
for c in input_channels:
pos = ids.index(str(c.pk))
if pos != c.position: # Save unneccessary UPDATE queries
c.position = pos
c.save(update_fields=['position'])
c.log_action(
'pretix.saleschannel.reordered', user=request.user, data={
'position': pos,
}
)
return HttpResponse()
+1
View File
@@ -98,6 +98,7 @@ class BaseEditorView(EventPermissionRequiredMixin, TemplateView):
from pretix.base.models import Order from pretix.base.models import Order
order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(), order = self.request.event.orders.create(status=Order.STATUS_PENDING, datetime=now(),
email='sample@pretix.eu', email='sample@pretix.eu',
sales_channel=self.request.event.organizer.sales_channels.get(identifier="web"),
locale=self.request.event.settings.locale, locale=self.request.event.settings.locale,
expires=now(), code="PREVIEW1234", total=Decimal('119.00')) expires=now(), code="PREVIEW1234", total=Decimal('119.00'))
+11 -12
View File
@@ -64,7 +64,8 @@ def casual_reads():
class GroupConcat(Aggregate): class GroupConcat(Aggregate):
function = 'group_concat' function = 'group_concat'
template = '%(function)s(%(field)s, "%(separator)s")' template = '%(function)s(%(distinct)s%(field)s, "%(separator)s")'
allow_distinct = True
def __init__(self, *expressions, ordered=False, **extra): def __init__(self, *expressions, ordered=False, **extra):
self.ordered = ordered self.ordered = ordered
@@ -73,19 +74,17 @@ class GroupConcat(Aggregate):
extra.update({'separator': ','}) extra.update({'separator': ','})
super().__init__(*expressions, **extra) super().__init__(*expressions, **extra)
def as_postgresql(self, compiler, connection): def as_postgresql(self, compiler, connection, **extra_context):
if self.ordered: if self.ordered:
return super().as_sql( template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s' ORDER BY %(field)s::text ASC)"
compiler, connection,
function='string_agg',
template="%(function)s(%(field)s::text, '%(separator)s' ORDER BY %(field)s ASC)",
)
else: else:
return super().as_sql( template = "%(function)s(%(distinct)s%(field)s::text, '%(separator)s')"
compiler, connection, return super().as_sql(
function='string_agg', compiler, connection,
template="%(function)s(%(field)s::text, '%(separator)s')", function='string_agg',
) template=template,
**extra_context,
)
class ReplicaRouter: class ReplicaRouter:

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