Compare commits

...
Author SHA1 Message Date
Raphael MichelandGitHub 73bec00d0c Update src/pretix/control/forms/filter.py 2023-04-17 09:34:56 +02:00
Maximilian Richt 879ae386ac Add checkin requires attention to advanced order search 2023-04-12 10:31:24 +02:00
Raphael Michel d19cdfb83f Fix missing JavaScript in question form 2023-04-06 11:38:39 +02:00
Raphael Michel b404f6d619 Fix crash in settings form validation 2023-04-06 10:35:29 +02:00
dda1368d81 Add option to copy shop URL and generate QR code (#3215)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-04-06 10:04:11 +02:00
Raphael MichelandGitHub ddade60625 Question: Allow limit of string length (#3214) 2023-04-06 09:58:50 +02:00
Raphael Michel b1e8e072d4 PDF exporters: Fix ordering of add-on products by name 2023-04-06 09:34:47 +02:00
Raphael MichelandGitHub b6ade23c50 Add webhook for pretix.event.order.payment.confirmed (#3216) 2023-04-06 09:21:36 +02:00
Raphael Michel 6573578ef1 Refs #3211 -- Add code comment on ical organizer field 2023-04-05 17:56:16 +02:00
Raphael Michel 33fc752a5f PDF Editor: Set width after text 2023-04-05 17:51:34 +02:00
Raphael Michel ff043e98f3 Bump stripe to 5.4.* 2023-04-05 14:45:41 +02:00
Raphael Michel ddfe065ebd Bump pypdf to 3.7.* 2023-04-05 14:45:10 +02:00
Raphael Michel bd5b63a90e Widget: Fix tests 2023-04-05 12:01:33 +02:00
Raphael Michel 0432798d23 Waiting list: Add mail placeholder for name 2023-04-05 11:28:27 +02:00
Raphael Michel ecb2865cb8 Widget: Add hidden location in detail view 2023-04-05 11:22:40 +02:00
Raphael Michel 43a05e1cb3 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-04-05 11:17:30 +02:00
Raphael MichelandRaphael Michel fa28b9c13a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5251 of 5251 strings)

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

powered by weblate
2023-04-05 11:16:56 +02:00
Raphael MichelandRaphael Michel 8a2b38792a Translations: Update German
Currently translated at 100.0% (5251 of 5251 strings)

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

powered by weblate
2023-04-05 11:16:56 +02:00
Raphael Michel 6652c2e61a Translations: Update wordlist 2023-04-05 11:11:13 +02:00
Raphael Michel 88b3f588b8 Fix settings import 2023-04-05 11:03:03 +02:00
Raphael Michel eabcececb0 Translations: Update wordlist 2023-04-05 11:02:37 +02:00
dependabot[bot]andRaphael Michel 31a2a40bce Bump @babel/core from 7.21.0 to 7.21.4 in /src/pretix/static/npm_dir
Bumps [@babel/core](https://github.com/babel/babel/tree/HEAD/packages/babel-core) from 7.21.0 to 7.21.4.
- [Release notes](https://github.com/babel/babel/releases)
- [Changelog](https://github.com/babel/babel/blob/main/CHANGELOG.md)
- [Commits](https://github.com/babel/babel/commits/v7.21.4/packages/babel-core)

---
updated-dependencies:
- dependency-name: "@babel/core"
  dependency-type: direct:production
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-04-04 22:45:37 +02:00
Raphael Michel a6e897b481 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2023-04-04 22:38:45 +02:00
전윤수andRaphael Michel a000bacb1f Translations: Update Korean
Currently translated at 0.1% (10 of 5195 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
전윤수andRaphael Michel b60d38ff3b Translations: Update Korean
Currently translated at 0.1% (2 of 5195 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
MichaelandRaphael Michel dea47a4e87 Translations: Update Czech
Currently translated at 79.2% (4120 of 5197 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
전윤수andRaphael Michel 78e2ea4fef Translations: Add Korean 2023-04-04 22:37:32 +02:00
chondaen12andRaphael Michel 9173832ce1 Translations: Update Thai
Currently translated at 0.2% (11 of 5195 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
Vasco BaleiaandRaphael Michel d7c9e82bae Translations: Update Portuguese (Portugal)
Currently translated at 93.4% (4855 of 5195 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
Hana HapplandRaphael Michel fb7a540efb Translations: Update Czech
Currently translated at 77.5% (4032 of 5197 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
MichaelandRaphael Michel f57a9cbae0 Translations: Update Czech
Currently translated at 77.5% (4032 of 5197 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
MichaelandRaphael Michel fdff1c80d3 Translations: Update Czech
Currently translated at 73.9% (3841 of 5197 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
MichaelandRaphael Michel 986122e7f8 Translations: Update Czech
Currently translated at 73.7% (3833 of 5197 strings)

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

powered by weblate
2023-04-04 22:37:32 +02:00
Raphael Michel 5dbb01342a Fix changing event settings 2023-04-04 19:31:03 +02:00
Raphael Michel f81c388906 Order details: Make "view in backend" open in new tab 2023-04-04 18:33:32 +02:00
Richard Schreiber 63bc6c17c9 Fix isort 2023-04-03 13:43:33 +02:00
Richard SchreiberandGitHub 5c8d1fde32 PDF: add attendee_name_parts fallback for addons (#3206) 2023-04-03 13:39:28 +02:00
d0b449ea89 Reusable media (#3131)
Co-authored-by: Martin Gross <gross@rami.io>
2023-04-03 10:45:22 +02:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
377117548d Bump @babel/preset-env from 7.20.2 to 7.21.4 in /src/pretix/static/npm_dir (#3204)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-04-03 10:26:42 +02:00
Raphael MichelandGitHub 634445b79d Check-in API: Extend reach of "force" flag (#3187) 2023-04-03 10:26:25 +02:00
Raphael Michel 496e4c800a PDF: Do not use internal names in addon list 2023-04-03 10:17:53 +02:00
Richard SchreiberandGitHub 83fe53ac2b Docs: add possible values for data-consent in widget (#3198) 2023-03-31 10:35:51 +02:00
Raphael Michel 534e6eb32d Bump redis to 4.5.4+ 2023-03-30 09:20:22 +02:00
Raphael Michel 23b863df96 Voucher bulk cration: Add heuristical error message for CSV input 2023-03-29 17:55:56 +02:00
Richard SchreiberandGitHub fbca5e3ab1 Widget: fix centering for close-svg (#3192) 2023-03-29 11:04:36 +02:00
Richard SchreiberandGitHub 37f6b7023c Widget: fix default-open variations being hidden (Z#23118652) 2023-03-29 10:02:47 +02:00
Raphael Michel d5ed1b87a1 Create log entry for ordering items, categories and questions 2023-03-28 17:42:17 +02:00
Raphael Michel 2b0f754f4b Widget: improve accessibility with aria-label and role=button (#3179)
Including fixes for previous commit
2023-03-28 15:14:49 +02:00
Raphael Michel aaebcae12b Revert "Widget: improve accessibility with aria-label and role=button (#3179)"
This reverts commit 2b479f59d5.
2023-03-28 15:03:14 +02:00
Raphael Michel 8096e958b8 Bump version to 4.19.0.dev0 2023-03-28 13:37:38 +02:00
Raphael Michel 1eb3a09240 Bump version to 4.18.0 2023-03-28 13:36:57 +02:00
Raphael Michel b52cbf2645 PDF editor: Fix layout not being saved 2023-03-28 12:49:21 +02:00
Raphael MichelandRaphael Michel 40679bc0d8 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5195 of 5195 strings)

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

powered by weblate
2023-03-28 12:41:14 +02:00
Raphael MichelandRaphael Michel 28c04acd81 Translations: Update German
Currently translated at 100.0% (5195 of 5195 strings)

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

powered by weblate
2023-03-28 12:41:14 +02:00
Raphael MichelandRaphael Michel 0de588a566 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5195 of 5195 strings)

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

powered by weblate
2023-03-28 12:41:14 +02:00
Raphael MichelandRaphael Michel a3bf7e37e7 Translations: Update German
Currently translated at 100.0% (5195 of 5195 strings)

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

powered by weblate
2023-03-28 12:41:14 +02:00
MichaelandRaphael Michel c91f2d9f7d Translations: Update Czech
Currently translated at 72.0% (3737 of 5186 strings)

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

powered by weblate
2023-03-28 12:41:14 +02:00
Raphael Michel 1511c6c2b4 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-03-28 10:31:33 +02:00
Raphael Michel 550826af76 Widget: Support for low availability in mobile calendar mode 2023-03-28 10:09:13 +02:00
Richard SchreiberandGitHub 2b479f59d5 Widget: improve accessibility with aria-label and role=button (#3179) 2023-03-28 09:49:15 +02:00
Raphael MichelandGitHub 861c689410 PDF editor: Small UX improvements (#3185) 2023-03-28 09:47:37 +02:00
Raphael MichelandGitHub c612f183ef Disable email rules if event is not live (#3181) 2023-03-28 09:23:42 +02:00
robbi5andGitHub 2b482dd233 Add system question order to device event settings api endpoint (#3186) 2023-03-27 18:06:55 +02:00
MichaelandRaphael Michel 3c9874b328 Translations: Update Czech
Currently translated at 71.4% (3704 of 5186 strings)

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

powered by weblate
2023-03-27 09:31:50 +02:00
MichaelandRaphael Michel 342506c866 Translations: Update Czech
Currently translated at 69.8% (3624 of 5186 strings)

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

powered by weblate
2023-03-27 09:31:50 +02:00
MichaelandRaphael Michel bc84664b1f Translations: Update Czech
Currently translated at 68.2% (3540 of 5186 strings)

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

powered by weblate
2023-03-27 09:31:50 +02:00
MichaelandRaphael Michel 7b8f7f48b9 Translations: Update Czech
Currently translated at 66.1% (3430 of 5186 strings)

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

powered by weblate
2023-03-27 09:31:50 +02:00
MichaelandRaphael Michel b1c252a646 Translations: Update Czech
Currently translated at 65.3% (3391 of 5186 strings)

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

powered by weblate
2023-03-27 09:31:50 +02:00
2774eb442d Bank transfer: Enable organizer-level features with multiple currencies (#3177)
Co-authored-by: Martin Gross <gross@rami.io>
2023-03-27 09:31:41 +02:00
Raphael Michel 27f0ed69d7 Promote Czeck language to inofficial 2023-03-23 09:09:12 +01:00
Raphael Michel e1b1bb93bc Bump Stripe to 5.2.* 2023-03-22 16:01:17 +01:00
Raphael Michel 35f160929c Bump pypdf to 3.6.* 2023-03-22 16:01:17 +01:00
Raphael Michel 70507a3a14 Bump django-hijack to 3.3.* 2023-03-22 16:01:17 +01:00
MichaelandRaphael Michel 79c969beab Translations: Update Czech
Currently translated at 57.8% (3001 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 46cd5428ff Translations: Update Czech
Currently translated at 56.5% (2935 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 0d4a6fc30a Translations: Update Czech
Currently translated at 55.4% (2877 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
chondaen12andRaphael Michel 51abcacf12 Translations: Update Thai
Currently translated at 0.1% (3 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Raphael MichelandRaphael Michel 88a7ef14e1 Translations: Update Czech
Currently translated at 54.6% (2833 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Hana HapplandRaphael Michel 81624c63b5 Translations: Update Czech
Currently translated at 54.6% (2833 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 7e070b5a3f Translations: Update Czech
Currently translated at 54.6% (2833 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Hana HapplandRaphael Michel d78c2f4941 Translations: Update Czech
Currently translated at 53.0% (2749 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel c27d9b713f Translations: Update Czech
Currently translated at 53.0% (2749 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel e4107360a1 Translations: Update Czech
Currently translated at 100.0% (205 of 205 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel d9e53df6ef Translations: Update Czech
Currently translated at 46.0% (2390 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 6456a6ae65 Translations: Update Czech
Currently translated at 84.3% (173 of 205 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 624fd9e1be Translations: Update Czech
Currently translated at 39.5% (2051 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 1d85857c87 Translations: Update Czech
Currently translated at 38.6% (2005 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 8ff5f543b2 Translations: Update Czech
Currently translated at 37.3% (1936 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Hana HapplandRaphael Michel ad06aa3e67 Translations: Update Czech
Currently translated at 37.3% (1936 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Hana HapplandRaphael Michel dc7cbe8c46 Translations: Update Czech
Currently translated at 37.0% (1920 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel dd7f0ca0e4 Translations: Update Czech
Currently translated at 37.0% (1920 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 93d799d9b9 Translations: Update Czech
Currently translated at 84.3% (173 of 205 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Hana HapplandRaphael Michel e725b384e3 Translations: Update Czech
Currently translated at 32.6% (1695 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
MichaelandRaphael Michel 63852ad344 Translations: Update Czech
Currently translated at 32.6% (1695 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Hana HapplandRaphael Michel 170399a883 Translations: Update Czech
Currently translated at 29.9% (1551 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
Hana HapplandRaphael Michel 0dd274287c Translations: Update Czech
Currently translated at 29.0% (1506 of 5186 strings)

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

powered by weblate
2023-03-22 15:59:15 +01:00
chondaen12andRaphael Michel eebe9c7237 Translations: Add Thai 2023-03-22 15:59:15 +01:00
Richard SchreiberandGitHub d4b210c164 Widget: add role=button to close element (#3174) 2023-03-22 15:58:53 +01:00
Richard SchreiberandGitHub 8a6488fd81 Order-exports: localize salutation (#3160) 2023-03-22 13:12:38 +01:00
AtlasandGitHub c52ebb4ba9 Fix minor typos & phrasing in scaling docs (#3175)
* Fix minor typos & phrasing in scaling docs

* Update scaling.rst
2023-03-21 20:03:36 +01:00
Raphael Michel 0121e053f6 Fix non-functional login page after session timeout 2023-03-21 17:19:11 +01:00
Richard SchreiberandGitHub 464a25a678 Widget: fix missing voucher in seating (Z#23118206) 2023-03-21 12:21:21 +01:00
Richard SchreiberandGitHub 3eceb33cfc Tests: ignore locale from pretix.cfg and default to en (#3167) 2023-03-21 08:55:34 +01:00
e9b22b7d33 Cart: ensure free price input is decimal (PRETIXEU-80N)
Co-authored-by: Phin Wolkwitz <wolkwitz@rami.io>
2023-03-21 08:51:49 +01:00
Richard SchreiberandGitHub 5ad0f92776 Widget: fix close-icon position 2023-03-21 08:46:55 +01:00
Raphael Michel 17574e8a23 Add email placeholder positionid 2023-03-20 17:26:49 +01:00
Raphael Michel de7314edcc Tax rule editor: Fix label for "text on invoice" 2023-03-20 17:19:32 +01:00
Raphael Michel df25a1cebf Invoice renderer: Line break on very large amounts 2023-03-20 16:46:44 +01:00
Raphael Michel 0578955273 API: Fix crash on invalid input 2023-03-20 16:46:44 +01:00
Raphael Michel 0b4daa9b16 Do not use "nonce" in user-exposed strings, as it is a slur in Britisch Englisch 2023-03-17 22:07:37 +01:00
Raphael Michel 8dfc77a927 Manual payment: Support for "invoice immediately" 2023-03-17 15:25:51 +01:00
Raphael Michel d0f603283b OrderChangeManager: Fix invoice regeneration on tax rate change 2023-03-17 13:41:18 +01:00
Raphael Michel 3df61b8fb5 Remove debug output 2023-03-17 13:32:52 +01:00
Raphael Michel fdead71884 Optionally allow self-service order changes after check-in 2023-03-17 09:22:44 +01:00
Raphael Michel 369251b0b0 Fix confusion between tax rate and tax value 2023-03-16 22:47:23 +01:00
Raphael Michel e83798a9b7 Increase validated size of prices 2023-03-16 21:33:06 +01:00
Raphael Michel 4c9640561c Increase size of monetary decimal fields 2023-03-16 21:26:37 +01:00
Raphael Michel e9ab0d8654 OrderChangeManager: Fix typo 2023-03-15 16:38:19 +01:00
Raphael MichelandGitHub c9e5cce7d0 Voucher bulk creation: Add markdown preview for email field (#3143) 2023-03-15 15:55:34 +01:00
Raphael MichelandGitHub 859004ec59 Use more sensible defaults for check-in lists in event series (#3147) 2023-03-14 22:26:40 +01:00
Raphael Michel 136511f394 Order.send_email: Support for attach_cached_files 2023-03-14 21:46:31 +01:00
Raphael Michel ee4081d9c3 API: Allow to filter events by testmode attribute 2023-03-10 16:34:54 +01:00
Raphael Michel 28b4982161 Check-in log: Add select2 for item selection 2023-03-10 15:46:27 +01:00
Raphael Michel 5c8a3f18f3 Check-in log: Add select2 for gate selection 2023-03-10 15:21:25 +01:00
Raphael Michel 440d1b5766 Export: Fix inconsistent file name 2023-03-10 15:17:31 +01:00
Raphael Michel 1ff8c6f78b PDF renderer: Place hidden text inside page rect 2023-03-10 14:26:50 +01:00
Raphael Michel 71e6a85c38 Make it easier to copy ticket secrets to clipboard 2023-03-09 18:15:54 +01:00
Raphael Michel 497e6f5c8f Check-in log: Add select2 for device selection 2023-03-09 18:15:39 +01:00
Raphael Michel 1d60827fa1 Check-in log export: Allow to filter by date range 2023-03-09 17:48:43 +01:00
Raphael Michel 3bbed98844 Fix a potentially destructive bug in 61ae434ab 2023-03-08 23:48:45 +01:00
Raphael Michel 8b8ad34d30 Order change: Should not set user for order change manager in presale 2023-03-08 23:32:12 +01:00
Raphael MichelandRaphael Michel e64034ed90 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5186 of 5186 strings)

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

powered by weblate
2023-03-08 16:37:47 +01:00
Raphael MichelandRaphael Michel b185dce17c Translations: Update German
Currently translated at 100.0% (5186 of 5186 strings)

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

powered by weblate
2023-03-08 16:37:47 +01:00
Raphael Michel 2ffa2315ca Translations: Add word to spellcheck ist 2023-03-08 16:28:07 +01:00
Raphael Michel b0616ed00d Scheduled exports: Fix serialization problem 2023-03-08 16:09:33 +01:00
Raphael Michel c36b9bcfcd Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-03-08 16:04:03 +01:00
Raphael Michel 2533ae2b3a order_overview: Add base_fees_qs parameter 2023-03-08 16:03:29 +01:00
Raphael MichelandGitHub 61ae434ab1 Allow attendees to change selected add-ons of same price (#3150) 2023-03-08 16:01:59 +01:00
alemao8andRaphael Michel 2ebbe82baf Translations: Update Greek
Currently translated at 54.4% (2820 of 5177 strings)

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

powered by weblate
2023-03-08 15:19:20 +01:00
MichaelandRaphael Michel 153eb67300 Translations: Update Czech
Currently translated at 28.9% (1499 of 5177 strings)

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

powered by weblate
2023-03-08 15:19:20 +01:00
Raphael Michel a2ed32be8b Fix buggy check in question answer shredder 2023-03-07 11:40:36 +01:00
Raphael Michel b5c94fd002 Fix changing order in extended order search 2023-03-07 11:03:36 +01:00
Raphael Michel 1b02a898a1 Fix TypeError in OAuth authorization 2023-03-07 10:43:07 +01:00
Raphael MichelandGitHub f29aa73f8d Event series calendar: Allow to hide all past events (#3142) 2023-03-06 18:25:45 +01:00
Raphael Michel 62cbed4891 Merge branch 'security-20230306' 2023-03-06 14:36:33 +01:00
Raphael Michel 68e31b92fe Make shredder code more robust 2023-03-06 14:25:59 +01:00
Raphael Michel 9a90444cca OAuth: Log authorized organizers 2023-03-06 14:25:59 +01:00
MichaelandRaphael Michel 18f57ea012 Translations: Update Czech
Currently translated at 25.8% (1337 of 5177 strings)

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

powered by weblate
2023-03-06 12:06:52 +01:00
Richard SchreiberandGitHub 08a85b3dab Order edit form: fix yes/no-question not being optional (Z#23117305) (#3149) 2023-03-06 12:03:08 +01:00
Raphael MichelandGitHub 81a5e263cb Allow to set privacy policy URL per language (#3146) 2023-03-06 12:02:52 +01:00
Raphael Michel 926d334b10 [SECURITY] Enforce session validation on oauth authorize endpoint 2023-03-06 11:52:01 +01:00
Raphael Michel 9bed40fa09 Add name of waiting list entry to voucher 2023-03-03 14:42:32 +01:00
Raphael Michel ed1dae5fde API: Fix duplicate results when filtering orders 2023-03-03 14:36:40 +01:00
Richard SchreiberandGitHub c7060d188f Product list: Indent variations, improve alignment of price and quantity (#3135) 2023-03-03 14:26:11 +01:00
Raphael Michel 9a53dc9c5e Move from PyPDF2 to pypdf again 2023-03-03 14:06:01 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
d1e0a7293b Bump @babel/core from 7.20.12 to 7.21.0 in /src/pretix/static/npm_dir (#3140)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-03-02 14:06:59 +01:00
Raphael Michel 7d5b1eebcb Fix type error in SafeWorksheet 2023-03-02 11:54:49 +01:00
Raphael Michel e4a02c494e Event settings: Clarify low_availability_percentage is a percentage 2023-03-02 10:56:24 +01:00
Raphael Michel 6ecac70727 shred_log_fields: Only mark shredded if something happened 2023-03-02 10:56:14 +01:00
Raphael Michel 8a1554323e Mail settings: Fix preview for mail_text_order_pending_warning 2023-03-01 16:31:52 +01:00
Raphael Michel cfc22c806a Fix possible crash in OrderChangeManager (PRETIXEU-7ZY) 2023-03-01 13:55:07 +01:00
Raphael Michel f70d6877dc Limit maximum value of transaction at gift card creation (PRETIXEU-870) 2023-03-01 13:32:03 +01:00
Raphael MichelandRaphael Michel 4dfad2ef42 Translations: Update Portuguese (Portugal)
Currently translated at 93.8% (4860 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Raphael MichelandRaphael Michel dc1d35cb1f Translations: Update Portuguese (Portugal)
Currently translated at 93.8% (4861 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Raphael MichelandRaphael Michel 6e657db882 Translations: Update Portuguese (Portugal)
Currently translated at 93.8% (4861 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Raphael MichelandRaphael Michel 91d3aaf20b Translations: Update Italian
Currently translated at 18.1% (938 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Raphael MichelandRaphael Michel 2f1318e2b9 Translations: Update Portuguese (Portugal)
Currently translated at 93.8% (4861 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Daniel LopezandRaphael Michel 844a74d33f Translations: Update Finnish
Currently translated at 17.0% (885 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Vasco BaleiaandRaphael Michel 76788a874a Translations: Update Portuguese (Portugal)
Currently translated at 93.9% (4862 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Michele PagnozziandRaphael Michel e29d5c37cd Translations: Update Italian
Currently translated at 18.1% (938 of 5177 strings)

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

powered by weblate
2023-03-01 10:51:56 +01:00
Raphael Michel 258e66587e Address form: Fix state selection for Mexico to include Mexico City 2023-03-01 09:57:12 +01:00
Raphael Michel d13af2eeab Translations: Use more neutral term for taxes in German due to different terminology in Switzerland 2023-02-27 12:13:06 +01:00
Raphael Michel 14b9afcc40 Bump version to 4.18.0.dev0 2023-02-27 09:27:42 +01:00
277 changed files with 178762 additions and 98233 deletions
+11 -11
View File
@@ -25,7 +25,7 @@ and what you should think of.
Scaling reasons
---------------
There's mainly two reasons to scale up a pretix installation beyond a single server:
There are two main reasons for scaling up a pretix installation beyond a single server:
* **Availability:** Distributing pretix over multiple servers can allow you to survive failure of one or more single machines, leading to a higher uptime and reliability of your system.
@@ -92,7 +92,7 @@ them from a different URL <config-urls>`.
pretix-web
""""""""""
The ``pretix-web`` process does not carry any internal state can be easily started on as many machines as you like, and you can
The ``pretix-web`` process does not carry any internal state and can be easily started on as many machines as you like, and you can
use the load balancing features of your frontend web server to redirect to all of them.
You can adjust the number of processes in the ``gunicorn`` command line, and we recommend choosing roughly two times the number
@@ -154,7 +154,7 @@ files, otherwise you **will** run into errors with the user interface.
The easiest solution for this is probably to store them on a NFS server that you mount
on each of the other servers.
Since we use Django's file storage mechanism internally, you can in theory also use a object-storage solution like Amazon S3, Ceph, or Minio to store these files, although we currently do not expose this through pretix' configuration file and this would require you to ship your own variant of ``pretix/settings.py`` and reference it through the ``DJANGO_SETTINGS_MODULE`` environment variable.
Since we use Django's file storage mechanism internally, you can in theory also use an object-storage solution like Amazon S3, Ceph, or Minio to store these files, although we currently do not expose this through pretix' configuration file and this would require you to ship your own variant of ``pretix/settings.py`` and reference it through the ``DJANGO_SETTINGS_MODULE`` environment variable.
At pretix.eu, we use a custom-built `object storage cluster`_.
@@ -171,12 +171,12 @@ you configure, so make sure to set this memory usage as high as you can afford.
memory available allows your database to make more use of caching, which is usually good.
Scaling your database to multiple machines needs to be treated with great caution. It's a
good to have a replica of your database for availability reasons. In case your primary
good idea to have a replica of your database for availability reasons. In case your primary
database server fails, you can easily switch over to the replica and continue working.
However, using database replicas for performance gains is much more complicated. When using
However, using database replicas for performance gain is much more complicated. When using
replicated database systems, you are always trading in consistency or availability to get
additional performance and the consequences of this can be subtle and it is important
additional performance and the consequences of this can be subtle. It is important
that you have a deep understanding of the semantics of your replication mechanism.
.. warning::
@@ -187,7 +187,7 @@ that you have a deep understanding of the semantics of your replication mechanis
As an example, if you buy a ticket, pretix first needs to calculate how many tickets
are left to sell. If this calculation is done on a database replica that lags behind
even for fractions of a second, the decision to allow selling the ticket will be made
on out-of-data data and you can end up with more tickets sold than configured. Similarly,
on stale data and you can end up with more tickets sold than configured. Similarly,
you could imagine situations leading to double payments etc.
If you do have a replica, you *can* tell pretix about it :ref:`in your configuration <config-replica>`.
@@ -204,9 +204,9 @@ redis
While redis is a very important part that glues together some of the components, it isn't used
heavily and can usually handle a fairly large pretix installation easily on a single modern
CPU core.
Having some memory available is good in case of e.g. lots of tasks queuing up during a traffic peak, but we wouldn't expect ever needing more than a gigabyte of it.
Having some memory available is good, e.g. if lots of tasks queue up during a traffic peak, but we wouldn't expect ever needing more than a gigabyte of it.
Feel free to set up a redis cluster for availability but you won't need it for performance in a long time.
Feel free to set up a redis cluster for availability but you probably won't need it for performance.
The limitations
---------------
@@ -228,9 +228,9 @@ if you add more hardware.
If you have an unlimited number of tickets, we can apply fewer locking and we've reached **approx.
1500 orders per minute per event** in benchmarks, although even more should be possible.
We're working to reduce the number of cases in which this is relevant and thereby improve the possible
We're working on reducing the number of cases in which this is relevant and thereby improve the possible
throughput. If you want to use pretix for an event with 10,000+ tickets that are likely to be sold out
within minutes, please get in touch to discuss possible solutions. We'll work something out for you!
.. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/
.. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/
-1
View File
@@ -225,4 +225,3 @@ You can get three response codes:
"subevent": 23,
"checkinlist": 5
}
+8 -2
View File
@@ -13,6 +13,10 @@ failed scans.
The endpoints listed on this page have been added.
.. versionchanged:: 4.18
The ``source_type`` parameter has been added.
.. _`rest-checkin-redeem`:
Checking a ticket in
@@ -28,6 +32,7 @@ Checking a ticket in
passed needs to be from a distinct event.
:<json string secret: Scanned QR code corresponding to the ``secret`` attribute of a ticket.
:<json string source_type: Type of source the ``secret`` was obtained form. Defaults to ``"barcode"``.
:<json array lists: List of check-in list IDs to search on. No two check-in lists may be from the same event.
:<json string type: Send ``"exit"`` for an exit and ``"entry"`` (default) for an entry.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
@@ -72,6 +77,7 @@ Checking a ticket in
{
"secret": "M5BO19XmFwAjLd4nDYUAL9ISjhti0e9q",
"source_type": "barcode",
"lists": [1],
"force": false,
"ignore_unpaid": false,
@@ -213,8 +219,8 @@ Checking a ticket in
* ``revoked`` - Ticket code has been revoked.
* ``error`` - Internal error.
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable
description of the violated rules. However, that field can also be missing or be ``null``.
In case of reason ``rules`` and ``invalid_time``, there might be an additional response field ``reason_explanation``
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 201: no error
+2 -2
View File
@@ -753,8 +753,8 @@ Order position endpoints
* ``ambiguous`` - Multiple tickets match scan, rejected.
* ``revoked`` - Ticket code has been revoked.
In case of reason ``rules``, there might be an additional response field ``reason_explanation`` with a human-readable
description of the violated rules. However, that field can also be missing or be ``null``.
In case of reason ``rules`` or ``invalid_time``, there might be an additional response field ``reason_explanation``
with a human-readable description of the violated rules. However, that field can also be missing or be ``null``.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
+9
View File
@@ -137,6 +137,7 @@ Endpoints
:query page: The page number in case of a multi-page result set, default is 1
:query is_public: If set to ``true``/``false``, only events with a matching value of ``is_public`` are returned.
:query live: If set to ``true``/``false``, only events with a matching value of ``live`` are returned.
:query testmode: If set to ``true``/``false``, only events with a matching value of ``testmode`` are returned.
:query has_subevents: If set to ``true``/``false``, only events with a matching value of ``has_subevents`` are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
@@ -546,6 +547,9 @@ Therefore, we're also not including a list of the options here, but instead reco
to see available options. The ``explain=true`` flag enables a verbose mode that provides you with human-readable
information about the properties.
Note that some settings are read-only, e.g. because they can be read on event level but currently only be changed on
organizer level.
.. note:: Please note that this is not a complete representation of all event settings. You will find more settings
in the web interface.
@@ -592,6 +596,7 @@ information about the properties.
{
"value": "https://pretix.eu",
"label": "Imprint URL",
"readonly": false,
"help_text": "This should point e.g. to a part of your website that has your contact details and legal information."
}
},
@@ -605,6 +610,10 @@ information about the properties.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. versionchanged:: 4.18
The ``readonly`` flag has been added.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/settings/
Updates event settings. Note that ``PUT`` is not allowed here, only ``PATCH``.
+1
View File
@@ -32,6 +32,7 @@ at :ref:`plugin-docs`.
membershiptypes
memberships
giftcards
reusablemedia
carts
teams
devices
+17
View File
@@ -108,6 +108,9 @@ generate_tickets boolean If ``false``,
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out.
issue_giftcard boolean If ``true``, buying this product will yield a gift card.
media_policy string Policy on how to handle reusable media (experimental feature).
Possible values are ``null``, ``"new"``, ``"reuse"``, and ``"reuse_or_new"``.
media_type string Type of reusable media to work on (experimental feature). See :ref:`rest-reusablemedia` for possible choices.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations.
@@ -189,6 +192,10 @@ meta_data object Values set fo
The ``validity_*`` attributes have been added.
.. versionchanged:: 4.18
The ``media_policy`` and ``media_type`` attributes have been added.
Notes
-----
@@ -244,6 +251,8 @@ Endpoints
"admission": false,
"personalized": false,
"issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {},
"position": 0,
"picture": null,
@@ -373,6 +382,8 @@ Endpoints
"admission": false,
"personalized": false,
"issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {},
"position": 0,
"picture": null,
@@ -483,6 +494,8 @@ Endpoints
"admission": false,
"personalized": false,
"issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {},
"position": 0,
"picture": null,
@@ -580,6 +593,8 @@ Endpoints
"admission": false,
"personalized": false,
"issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {},
"position": 0,
"picture": null,
@@ -709,6 +724,8 @@ Endpoints
"admission": false,
"personalized": false,
"issue_giftcard": false,
"media_policy": null,
"media_type": null,
"meta_data": {},
"position": 0,
"picture": null,
+1
View File
@@ -910,6 +910,7 @@ Creating orders
* ``valid_from`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``valid_until`` (optional, if both ``valid_from`` and ``valid_until`` are **missing** (not ``null``) the availability will be computed from the given product)
* ``requested_valid_from`` (optional, can be set **instead** of ``valid_from`` and ``valid_until`` to signal a user choice for the start time that may or may not be respected)
* ``use_reusable_medium`` (optional, causes the new ticket to take over the given reusable medium, identified by its ID)
* ``answers``
* ``question``
+1
View File
@@ -157,6 +157,7 @@ information about the properties.
{
"value": "calendar",
"label": "Default overview style",
"readonly": false,
"help_text": "If your event series has more than 50 dates in the future, only the month or week calendar can be used."
}
},
+5
View File
@@ -63,6 +63,7 @@ valid_date_max date Maximum value f
valid_datetime_min datetime Minimum value for date and time questions (optional)
valid_datetime_max datetime Maximum value for date and time questions (optional)
valid_file_portrait boolean Turn on file validation for portrait photos
valid_string_length_max integer Maximum length for string questions (optional)
dependency_question integer Internal ID of a different question. The current
question will only be shown if the question given in
this attribute is set to the value given in
@@ -122,6 +123,7 @@ Endpoints
"valid_date_max": null,
"valid_datetime_min": null,
"valid_datetime_max": null,
"valid_string_length_max": null,
"valid_file_portrait": false,
"dependency_question": null,
"dependency_value": null,
@@ -201,6 +203,7 @@ Endpoints
"valid_datetime_min": null,
"valid_datetime_max": null,
"valid_file_portrait": false,
"valid_string_length_max": null,
"dependency_question": null,
"dependency_value": null,
"dependency_values": [],
@@ -302,6 +305,7 @@ Endpoints
"valid_datetime_min": null,
"valid_datetime_max": null,
"valid_file_portrait": false,
"valid_string_length_max": null,
"options": [
{
"id": 1,
@@ -384,6 +388,7 @@ Endpoints
"valid_datetime_min": null,
"valid_datetime_max": null,
"valid_file_portrait": false,
"valid_string_length_max": null,
"options": [
{
"id": 1,
+317
View File
@@ -0,0 +1,317 @@
.. _`rest-reusablemedia`:
Reusable media
==============
Reusable media represent things, typically physical tokens like plastic cards or NFC wristbands, which can represent
other entities inside the system. For example, a medium can link to an order position or to a gift card and can be used
in their place. Later, the medium might be reused for a different ticket.
Resource description
--------------------
The reusable medium resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the medium
type string Type of medium, e.g. ``"barcode"`` or ``"nfc_uid"``.
identifier string Unique identifier of the medium. The format depends on the ``type``.
active boolean Whether this medium may be used.
created datetime Date of creation
updated datetime Date of last modification
expires datetime Expiry date (or ``null``)
customer string Identifier of a customer account this medium belongs to.
linked_orderposition integer Internal ID of a ticket this medium is linked to.
linked_giftcard integer Internal ID of a gift card this medium is linked to.
info object Additional data, content depends on the ``type``. Consider
this internal to the system and don't use it for your own data.
notes string Internal notes and comments (or ``null``)
===================================== ========================== =======================================================
Existing media types are:
- ``barcode``
- ``nfc_uid``
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/reusablemedia/
Returns a list of all media issued by a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/reusablemedia/ 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": [
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1.
:query string identifier: Only show media with the given identifier. Note that you should use the lookup endpoint described below for most use cases.
:query string type: Only show media with the given type.
:query boolean active: Only show media that are (not) active.
:query string customer: Only show media linked to the given customer.
:query string created_since: Only show media created since a given date.
:query string updated_since: Only show media updated since a given date.
:query integer linked_orderposition: Only show media linked to the given ticket.
:query integer linked_giftcard: Only show media linked to the given gift card.
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
: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)/reusablemedia/(id)/
Returns information on one medium, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/reusablemedia/1/ 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
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the medium to fetch
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
: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)/reusablemedia/lookup/
Look up a new reusable medium by its identifier. In some cases, this might lead to the automatic creation of a new
medium behind the scenes.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/reusablemedia/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"identifier": "ABCDEFGH",
"type": "barcode",
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to look up a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 201: no error
:statuscode 400: The medium could not be looked up 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:post:: /api/v1/organizers/(organizer)/reusablemedia/
Creates a new reusable medium.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/reusablemedia/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"identifier": "ABCDEFGH",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": None,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to create a medium for
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 201: no error
:statuscode 400: The medium 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)/reusablemedia/(id)/
Update a reusable medium. 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 ``id``, ``identifier`` and ``type`` fields.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/reusablemedia/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"linked_orderposition": 13
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"identifier": "ABCDEFGH",
"created": "2021-04-06T13:44:22.809377Z",
"updated": "2021-04-06T13:44:22.809377Z",
"type": "barcode",
"active": True,
"expires": None,
"customer": None,
"linked_orderposition": 13,
"linked_giftcard": None,
"notes": None,
"info": {}
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the medium to modify
:query string expand: If you pass ``"linked_giftcard"``, ``"linked_orderposition"``, oder ``"customer"``, the respective
field will be shown as a nested value instead of just an ID. The nested objects are identical to
the respective resources, except that the ``linked_orderposition`` will have an attribute of the
format ``"order": {"code": "ABCDE", "event": "eventslug"}`` to make matching easier. The parameter
can be given multiple times.
:statuscode 200: no error
:statuscode 400: The medium 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.
+5
View File
@@ -26,6 +26,7 @@ can_create_events boolean
can_change_teams boolean
can_change_organizer_settings boolean
can_manage_customers boolean
can_manage_reusable_media boolean
can_manage_gift_cards boolean
can_change_event_settings boolean
can_change_items boolean
@@ -36,6 +37,10 @@ can_change_vouchers boolean
can_checkin_orders boolean
===================================== ========================== =======================================================
.. versionchanged:: 4.18
The ``can_manage_reusable_media`` permission has been added.
Team member resource
--------------------
+1
View File
@@ -47,6 +47,7 @@ The following values for ``action_types`` are valid with pretix core:
* ``pretix.event.order.refund.done``
* ``pretix.event.order.refund.canceled``
* ``pretix.event.order.refund.failed``
* ``pretix.event.order.payment.confirmed``
* ``pretix.event.order.approved``
* ``pretix.event.order.denied``
* ``pretix.event.checkin``
Binary file not shown.

Before

Width:  |  Height:  |  Size: 177 KiB

After

Width:  |  Height:  |  Size: 180 KiB

+12 -12
View File
@@ -38,27 +38,27 @@ else
endif
"Is the order in status PAID or PENDING\nand is the position not canceled?" --> if "" then
-right->[no] "Return error CANCELED"
-right->[no && !force] "Return error CANCELED"
else
-down->[yes] "Is one or more block set on the ticket?"
-down->[yes || force] "Is one or more block set on the ticket?"
--> if "" then
-right->[no] "Return error BLOCKED"
-right->[no && !force] "Return error BLOCKED"
else
-down->[yes] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?"
-down->[yes || force] "If this is not an exit, is the valid_from/valid_until\nconstraint on the ticket fulfilled?"
--> if "" then
-right->[no] "Return error INVALID_TIME"
-right->[no && !force] "Return error INVALID_TIME"
else
-down->[yes] "Is the product part of the check-in list?"
-down->[yes || force] "Is the product part of the check-in list?"
--> if "" then
-right->[no] "Return error PRODUCT"
-right->[no && !force] "Return error PRODUCT"
else
-down->[yes] "Is the subevent part of the check-in list?"
-down->[yes || force] "Is the subevent part of the check-in list?"
--> if "" then
-right->[no] "Return error PRODUCT "
-right->[no && !force] "Return error PRODUCT "
else
-down->[yes] "Is the order in status PAID\nor is this a forced upload?"
-down->[yes] "Is the order in status PAID?"
--> if "" then
-right->[no] "Is Order.require_approval set?"
-right->[no && !force] "Is Order.require_approval set?"
--> if "" then
-->[no] "Is Order.valid_if_pending set?"
--> if "" then
@@ -80,7 +80,7 @@ else
-->[yes] "Return error UNPAID "
endif
else
-down->[yes] "Is this an entry or exit?\nIs the upload forced?"
-down->[yes || force] "Is this an entry or exit?\nIs the upload forced?"
endif
endif
endif
+4 -1
View File
@@ -318,7 +318,10 @@ Currently, the following attributes are understood by pretix itself:
* If ``data-consent="…"`` is given, the cookie consent mechanism will be initialized with consent for the given cookie
providers. All other providers will be disabled, no consent dialog will be shown. This is useful if you already
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
asked the user for consent and don't want them to be asked again. Example: ``data-consent="facebook,google_analytics"``
When using the pretix-tracking plugin, the following values are supported::
``adform, facebook, gosquared, google_ads, google_analytics, hubspot, linkedin, matomo, twitter``
Any configured pretix plugins might understand more data fields. For example, if the appropriate plugins on pretix
Hosted or pretix Enterprise are active, you can pass the following fields:
+1 -1
View File
@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
__version__ = "4.17.0"
__version__ = "4.19.0.dev0"
+2
View File
@@ -81,6 +81,7 @@ class PretixScanSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'),
('GET', 'api-v1:reusablemedium-list'),
)
@@ -220,6 +221,7 @@ class PretixPosSecurityProfile(AllowListSecurityProfile):
('POST', 'api-v1:upload'),
('POST', 'api-v1:checkinrpc.redeem'),
('GET', 'api-v1:checkinrpc.search'),
('POST', 'api-v1:reusablemedium-lookup'),
)
+2
View File
@@ -26,6 +26,7 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.event import SubEventSerializer
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.models import Checkin, CheckinList
@@ -84,6 +85,7 @@ class CheckinRPCRedeemInputSerializer(serializers.Serializer):
lists = serializers.PrimaryKeyRelatedField(required=True, many=True, queryset=CheckinList.objects.none())
secret = serializers.CharField(required=True, allow_null=False)
force = serializers.BooleanField(default=False, required=False)
source_type = serializers.ChoiceField(choices=[(k, v) for k, v in MEDIA_TYPES.items()], default='barcode')
type = serializers.ChoiceField(choices=Checkin.CHECKIN_TYPES, default=Checkin.TYPE_ENTRY)
ignore_unpaid = serializers.BooleanField(default=False, required=False)
questions_supported = serializers.BooleanField(default=True, required=False)
+20
View File
@@ -693,6 +693,7 @@ class EventSettingsSerializer(SettingsSerializer):
'frontpage_subevent_ordering',
'event_list_type',
'event_list_available_only',
'event_calendar_future_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
@@ -784,6 +785,7 @@ class EventSettingsSerializer(SettingsSerializer):
'change_allow_user_addons',
'change_allow_user_until',
'change_allow_user_price',
'change_allow_attendee',
'primary_color',
'theme_color_success',
'theme_color_danger',
@@ -795,6 +797,21 @@ class EventSettingsSerializer(SettingsSerializer):
'logo_show_title',
'og_image',
'name_scheme',
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
]
readonly_fields = [
# These are read-only since they are currently only settable on organizers, not events
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
]
def __init__(self, *args, **kwargs):
@@ -861,6 +878,9 @@ class DeviceEventSettingsSerializer(EventSettingsSerializer):
'invoice_address_from_tax_id',
'invoice_address_from_vat_id',
'name_scheme',
'reusable_media_type_barcode',
'reusable_media_type_nfc_uid',
'system_question_order',
]
def __init__(self, *args, **kwargs):
+15
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from django.conf import settings
from django.core.validators import URLValidator
from i18nfield.fields import I18nCharField, I18nTextField
from i18nfield.strings import LazyI18nString
from rest_framework.exceptions import ValidationError
@@ -69,3 +70,17 @@ class I18nAwareModelSerializer(ModelSerializer):
I18nAwareModelSerializer.serializer_field_mapping[I18nCharField] = I18nField
I18nAwareModelSerializer.serializer_field_mapping[I18nTextField] = I18nField
class I18nURLField(I18nField):
def to_internal_value(self, value):
value = super().to_internal_value(value)
if not value:
return value
if isinstance(value.data, dict):
for v in value.data.values():
if v:
URLValidator()(v)
else:
URLValidator()(value.data)
return value
+9 -7
View File
@@ -52,7 +52,7 @@ from pretix.base.models import (
class InlineItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
@@ -76,7 +76,7 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
class ItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=13,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
@@ -244,7 +244,8 @@ class ItemSerializer(I18nAwareModelSerializer):
'grant_membership_duration_like_event', 'grant_membership_duration_days',
'grant_membership_duration_months', 'validity_mode', 'validity_fixed_from', 'validity_fixed_until',
'validity_dynamic_duration_minutes', 'validity_dynamic_duration_hours', 'validity_dynamic_duration_days',
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit')
'validity_dynamic_duration_months', 'validity_dynamic_start_choice', 'validity_dynamic_start_choice_day_limit',
'media_policy', 'media_type')
read_only_fields = ('has_variations',)
def __init__(self, *args, **kwargs):
@@ -263,6 +264,7 @@ class ItemSerializer(I18nAwareModelSerializer):
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until'))
Item.clean_media_settings(self.context['event'], data.get('media_policy'), data.get('media_type'), data.get('issue_giftcard'))
if data.get('personalized') and not data.get('admission'):
raise ValidationError(_('Only admission products can currently be personalized.'))
@@ -304,9 +306,9 @@ class ItemSerializer(I18nAwareModelSerializer):
if not self.instance:
for addon_data in value:
ItemAddOn.clean_categories(self.context['event'], None, self.instance, addon_data['addon_category'])
ItemAddOn.clean_min_count(addon_data['min_count'])
ItemAddOn.clean_max_count(addon_data['max_count'])
ItemAddOn.clean_max_min_count(addon_data['max_count'], addon_data['min_count'])
ItemAddOn.clean_min_count(addon_data.get('min_count', 0))
ItemAddOn.clean_max_count(addon_data.get('max_count', 0))
ItemAddOn.clean_max_min_count(addon_data.get('max_count', 0), addon_data.get('min_count', 0))
return value
@cached_property
@@ -440,7 +442,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden', 'dependency_value', 'print_on_invoice', 'help_text', 'valid_number_min',
'valid_number_max', 'valid_date_min', 'valid_date_max', 'valid_datetime_min', 'valid_datetime_max',
'valid_file_portrait')
'valid_string_length_max', 'valid_file_portrait')
def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance)
+128
View File
@@ -0,0 +1,128 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import logging
from decimal import Decimal
from django.utils.translation import gettext_lazy as _
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import OrderPositionSerializer
from pretix.api.serializers.organizer import (
CustomerSerializer, GiftCardSerializer,
)
from pretix.base.models import Order, OrderPosition, ReusableMedium
logger = logging.getLogger(__name__)
class NestedOrderMiniSerializer(I18nAwareModelSerializer):
event = serializers.SlugRelatedField(slug_field='slug', read_only=True)
class Meta:
model = Order
fields = ['code', 'event']
class NestedOrderPositionSerializer(OrderPositionSerializer):
order = NestedOrderMiniSerializer()
class NestedGiftCardSerializer(GiftCardSerializer):
def to_representation(self, instance):
d = super().to_representation(instance)
if hasattr(instance, 'cached_value'):
d['value'] = str(Decimal(instance.cached_value).quantize(Decimal("0.01")))
else:
d['value'] = str(Decimal(instance.value).quantize(Decimal("0.01")))
return d
class ReusableMediaSerializer(I18nAwareModelSerializer):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if 'linked_giftcard' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_giftcard'] = NestedGiftCardSerializer(read_only=True)
else:
self.fields['linked_giftcard'] = serializers.PrimaryKeyRelatedField(
required=False,
allow_null=True,
queryset=self.context['organizer'].issued_gift_cards.all()
)
if 'linked_orderposition' in self.context['request'].query_params.getlist('expand'):
self.fields['linked_orderposition'] = NestedOrderPositionSerializer(read_only=True)
else:
self.fields['linked_orderposition'] = serializers.PrimaryKeyRelatedField(
required=False,
allow_null=True,
queryset=OrderPosition.all.filter(order__event__organizer=self.context['organizer']),
)
if 'customer' in self.context['request'].query_params.getlist('expand'):
self.fields['customer'] = CustomerSerializer(read_only=True)
else:
self.fields['customer'] = serializers.SlugRelatedField(
required=False,
allow_null=True,
slug_field='identifier',
queryset=self.context['organizer'].customers.all()
)
def validate(self, data):
data = super().validate(data)
if 'type' in data and 'identifier' in data:
qs = self.context['organizer'].reusable_media.filter(
identifier=data['identifier'], type=data['type']
)
if self.instance:
qs = qs.exclude(pk=self.instance.pk)
if qs.exists():
raise ValidationError(
{'identifier': _('A medium with the same identifier and type already exists in your organizer account.')}
)
return data
class Meta:
model = ReusableMedium
fields = (
'id',
'created',
'updated',
'type',
'identifier',
'active',
'expires',
'customer',
'linked_orderposition',
'linked_giftcard',
'info',
'notes',
)
class MediaLookupInputSerializer(serializers.Serializer):
type = serializers.CharField(required=True)
identifier = serializers.CharField(required=True)
+33 -7
View File
@@ -33,6 +33,7 @@ from django.utils.encoding import force_str
from django.utils.timezone import now
from django.utils.translation import gettext_lazy
from django_countries.fields import Country
from django_scopes import scopes_disabled
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
@@ -48,8 +49,8 @@ from pretix.base.decimal import round_decimal
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, Customer, Invoice, InvoiceAddress, InvoiceLine, Item,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer, Seat,
SubEvent, TaxRule, Voucher,
ItemVariation, Order, OrderPosition, Question, QuestionAnswer,
ReusableMedium, Seat, SubEvent, TaxRule, Voucher,
)
from pretix.base.models.orders import (
BlockedTicketSecret, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -356,6 +357,9 @@ class PdfDataSerializer(serializers.Field):
def to_representation(self, instance: OrderPosition):
res = {}
if 'event' not in self.context:
return {}
ev = instance.subevent or instance.order.event
with language(instance.order.locale, instance.order.event.settings.region):
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
@@ -381,11 +385,9 @@ class PdfDataSerializer(serializers.Field):
res['meta:' + k] = v
if instance.variation_id:
print(instance, instance.variation, instance.variation_id, instance.item)
if not hasattr(instance.variation, '_cached_meta_data'):
instance.variation.item = instance.item # saves some database lookups
instance.variation._cached_meta_data = instance.variation.meta_data
print(instance.variation._cached_meta_data.items())
for k, v in instance.variation._cached_meta_data.items():
res['itemmeta:' + k] = v
else:
@@ -781,18 +783,20 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
max_digits=13)
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
required=False, allow_null=True)
country = CompatibleCountryField(source='*')
requested_valid_from = serializers.DateTimeField(required=False, allow_null=True)
use_reusable_medium = serializers.PrimaryKeyRelatedField(queryset=ReusableMedium.objects.none(),
required=False, allow_null=True)
class Meta:
model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'company', 'street', 'zipcode', 'city', 'country', 'state', 'is_bundled',
'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher', 'valid_from', 'valid_until',
'requested_valid_from')
'requested_valid_from', 'use_reusable_medium')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -801,6 +805,9 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
v.required = False
v.allow_blank = True
v.allow_null = True
with scopes_disabled():
if 'use_reusable_medium' in self.fields:
self.fields['use_reusable_medium'].queryset = ReusableMedium.objects.all()
def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -809,6 +816,13 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
)
return secret
def validate_use_reusable_medium(self, m):
if m.organizer_id != self.context['event'].organizer_id:
raise ValidationError(
'The specified medium does not belong to this organizer.'
)
return m
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
@@ -1266,7 +1280,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos_data['attendee_name_parts'] = {
'_legacy': attendee_name
}
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas'})
pos = OrderPosition(**{k: v for k, v in pos_data.items() if k != 'answers' and k != '_quotas' and k != 'use_reusable_medium'})
if simulate:
pos.order = order._wrapped
else:
@@ -1334,6 +1348,7 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
# Save instances
for pos_data in positions_data:
answers_data = pos_data.pop('answers', [])
use_reusable_medium = pos_data.pop('use_reusable_medium', None)
pos = pos_data['__instance']
pos._calculate_tax()
@@ -1372,6 +1387,17 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
if use_reusable_medium:
use_reusable_medium.linked_orderposition = pos
use_reusable_medium.save(update_fields=['linked_orderposition'])
use_reusable_medium.log_action(
'pretix.reusable_medium.linked_orderposition.changed',
data={
'by_order': order.code,
'linked_orderposition': pos.pk,
}
)
if not simulate:
for cp in delete_cps:
if cp.addon_to_id:
+1 -1
View File
@@ -47,7 +47,7 @@ class OrderPositionCreateForExistingOrderSerializer(OrderPositionCreateSerialize
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
max_digits=13)
country = CompatibleCountryField(source='*')
class Meta:
+8 -2
View File
@@ -128,7 +128,7 @@ class MembershipSerializer(I18nAwareModelSerializer):
class GiftCardSerializer(I18nAwareModelSerializer):
value = serializers.DecimalField(max_digits=10, decimal_places=2, min_value=Decimal('0.00'))
value = serializers.DecimalField(max_digits=13, decimal_places=2, min_value=Decimal('0.00'))
def validate(self, data):
data = super().validate(data)
@@ -183,7 +183,7 @@ class TeamSerializer(serializers.ModelSerializer):
'id', 'name', 'all_events', 'limit_events', 'can_create_events', 'can_change_teams',
'can_change_organizer_settings', 'can_manage_gift_cards', 'can_change_event_settings',
'can_change_items', 'can_view_orders', 'can_change_orders', 'can_view_vouchers',
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers'
'can_change_vouchers', 'can_checkin_orders', 'can_manage_customers', 'can_manage_reusable_media'
)
def validate(self, data):
@@ -333,6 +333,12 @@ class OrganizerSettingsSerializer(SettingsSerializer):
'cookie_consent_dialog_text_secondary',
'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no',
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
]
def __init__(self, *args, **kwargs):
+6
View File
@@ -36,6 +36,7 @@ logger = logging.getLogger(__name__)
class SettingsSerializer(serializers.Serializer):
default_fields = []
readonly_fields = []
def __init__(self, *args, **kwargs):
self.changed_data = []
@@ -59,8 +60,13 @@ class SettingsSerializer(serializers.Serializer):
f.parent = self
self.fields[fname] = f
def validate(self, attrs):
return {k: v for k, v in attrs.items() if k not in self.readonly_fields}
def update(self, instance: HierarkeyProxy, validated_data):
for attr, value in validated_data.items():
if attr in self.readonly_fields:
continue
if isinstance(value, FieldFile):
# Delete old file
fname = instance.get(attr, as_type=File)
+4 -3
View File
@@ -42,9 +42,9 @@ from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, device, discount, event, exporters, idempotency, item, oauth,
order, organizer, shredders, upload, user, version, voucher, waitinglist,
webhooks,
checkin, device, discount, event, exporters, idempotency, item, media,
oauth, order, organizer, shredders, upload, user, version, voucher,
waitinglist, webhooks,
)
router = routers.DefaultRouter()
@@ -59,6 +59,7 @@ orga_router.register(r'giftcards', organizer.GiftCardViewSet)
orga_router.register(r'customers', organizer.CustomerViewSet)
orga_router.register(r'memberships', organizer.MembershipViewSet)
orga_router.register(r'membershiptypes', organizer.MembershipTypeViewSet)
orga_router.register(r'reusablemedia', media.ReusableMediaViewSet)
orga_router.register(r'teams', organizer.TeamViewSet)
orga_router.register(r'devices', organizer.DeviceViewSet)
orga_router.register(r'exporters', exporters.OrganizerExportersViewSet, basename='exporters')
+105 -89
View File
@@ -59,7 +59,7 @@ from pretix.api.views.order import OrderPositionFilter
from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Checkin, CheckinList, Device, Event, Order, OrderPosition,
Question, RevokedTicketSecret, TeamAPIToken,
Question, ReusableMedium, RevokedTicketSecret, TeamAPIToken,
)
from pretix.base.services.checkin import (
CheckInError, RequiredQuestionsError, SQLLogic, perform_checkin,
@@ -396,7 +396,7 @@ def _checkin_list_position_queryset(checkinlists, ignore_status=False, ignore_pr
def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force, checkin_type, ignore_unpaid, nonce,
untrusted_input, user, auth, expand, pdf_data, request, questions_supported, canceled_supported,
legacy_url_support=False):
source_type='barcode', legacy_url_support=False):
if not checkinlists:
raise ValidationError('No check-in list passed.')
@@ -422,6 +422,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
common_checkin_args = dict(
raw_barcode=raw_barcode,
raw_source_type=source_type,
type=checkin_type,
list=checkinlists[0],
datetime=datetime,
@@ -433,7 +434,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
raw_barcode_for_checkin = None
from_revoked_secret = False
# 1. Gather a list of positions that could be the one we looking fore, either from their ID, secret or
# 1. Gather a list of positions that could be the one we looking for, either from their ID, secret or
# parent secret
queryset = _checkin_list_position_queryset(checkinlists, pdf_data=pdf_data, ignore_status=True, ignore_products=True).order_by(
F('addon_to').asc(nulls_first=True)
@@ -457,98 +458,111 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
# 2. Handle the "nothing found" case: Either it's really a bogus secret that we don't know (-> error), or it
# might be a revoked one that we actually know (-> error, but with better error message and logging and
# with respecting the force option).
# with respecting the force option), or it's a reusable medium (-> proceed with that)
if not op_candidates:
revoked_matches = list(RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
if len(revoked_matches) == 0:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
'datetime': datetime,
'type': checkin_type,
'list': checkinlists[0].pk,
'barcode': raw_barcode,
'searched_lists': [cl.pk for cl in checkinlists]
}, user=user, auth=auth)
try:
media = ReusableMedium.objects.select_related('linked_orderposition').active().get(
organizer_id=checkinlists[0].event.organizer_id,
type=source_type,
identifier=raw_barcode,
linked_orderposition__isnull=False,
)
raw_barcode_for_checkin = raw_barcode
except ReusableMedium.DoesNotExist:
revoked_matches = list(
RevokedTicketSecret.objects.filter(event_id__in=list_by_event.keys(), secret=raw_barcode))
if len(revoked_matches) == 0:
checkinlists[0].event.log_action('pretix.event.checkin.unknown', data={
'datetime': datetime,
'type': checkin_type,
'list': checkinlists[0].pk,
'barcode': raw_barcode,
'searched_lists': [cl.pk for cl in checkinlists]
}, user=user, auth=auth)
for cl in checkinlists:
for k, s in cl.event.ticket_secret_generators.items():
for cl in checkinlists:
for k, s in cl.event.ticket_secret_generators.items():
try:
parsed = s.parse_secret(raw_barcode)
common_checkin_args.update({
'raw_item': parsed.item,
'raw_variation': parsed.variation,
'raw_subevent': parsed.subevent,
})
except:
pass
Checkin.objects.create(
position=None,
successful=False,
error_reason=Checkin.REASON_INVALID,
**common_checkin_args,
)
if force and legacy_url_support and isinstance(auth, Device):
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
# valid at the time but no longer exists at time of upload, the device would retry to
# upload the same scan over and over again. Since we can't update all devices quickly,
# here's a dirty workaround to make it stop.
try:
parsed = s.parse_secret(raw_barcode)
common_checkin_args.update({
'raw_item': parsed.item,
'raw_variation': parsed.variation,
'raw_subevent': parsed.subevent,
})
except:
brand = auth.software_brand
ver = parse(auth.software_version)
legacy_mode = (
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
)
if legacy_mode:
return Response({
'status': 'error',
'reason': Checkin.REASON_ALREADY_REDEEMED,
'reason_explanation': None,
'require_attention': False,
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
}, status=400)
except: # we don't care e.g. about invalid version numbers
pass
Checkin.objects.create(
position=None,
successful=False,
error_reason=Checkin.REASON_INVALID,
**common_checkin_args,
)
if force and legacy_url_support and isinstance(auth, Device):
# There was a bug in libpretixsync: If you scanned a ticket in offline mode that was
# valid at the time but no longer exists at time of upload, the device would retry to
# upload the same scan over and over again. Since we can't update all devices quickly,
# here's a dirty workaround to make it stop.
try:
brand = auth.software_brand
ver = parse(auth.software_version)
legacy_mode = (
(brand == 'pretixSCANPROXY' and ver < parse('0.0.3')) or
(brand == 'pretixSCAN Android' and ver < parse('1.11.2')) or
(brand == 'pretixSCAN' and ver < parse('1.11.2'))
)
if legacy_mode:
return Response({
'status': 'error',
'reason': Checkin.REASON_ALREADY_REDEEMED,
'reason_explanation': None,
'require_attention': False,
'__warning': 'Compatibility hack active due to detected old pretixSCAN version',
}, status=400)
except: # we don't care e.g. about invalid version numbers
pass
return Response({
'detail': 'Not found.', # for backwards compatibility
'status': 'error',
'reason': Checkin.REASON_INVALID,
'reason_explanation': None,
'require_attention': False,
'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404)
elif revoked_matches and force:
op_candidates = [revoked_matches[0].position]
if list_by_event[revoked_matches[0].event_id].addon_match:
op_candidates += list(revoked_matches[0].position.addons.all())
raw_barcode_for_checkin = raw_barcode
from_revoked_secret = True
return Response({
'detail': 'Not found.', # for backwards compatibility
'status': 'error',
'reason': Checkin.REASON_INVALID,
'reason_explanation': None,
'require_attention': False,
'list': MiniCheckinListSerializer(checkinlists[0]).data,
}, status=404)
elif revoked_matches and force:
op_candidates = [revoked_matches[0].position]
if list_by_event[revoked_matches[0].event_id].addon_match:
op_candidates += list(revoked_matches[0].position.addons.all())
raw_barcode_for_checkin = raw_barcode_for_checkin or raw_barcode
from_revoked_secret = True
else:
op = revoked_matches[0].position
op.order.log_action('pretix.event.checkin.revoked', data={
'datetime': datetime,
'type': checkin_type,
'list': list_by_event[revoked_matches[0].event_id].pk,
'barcode': raw_barcode
}, user=user, auth=auth)
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
Checkin.objects.create(
position=op,
successful=False,
error_reason=Checkin.REASON_REVOKED,
**common_checkin_args
)
return Response({
'status': 'error',
'reason': Checkin.REASON_REVOKED,
'reason_explanation': None,
'require_attention': False,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[
0].event)).data,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400)
else:
op = revoked_matches[0].position
op.order.log_action('pretix.event.checkin.revoked', data={
'datetime': datetime,
'type': checkin_type,
'list': list_by_event[revoked_matches[0].event_id].pk,
'barcode': raw_barcode
}, user=user, auth=auth)
common_checkin_args['list'] = list_by_event[revoked_matches[0].event_id]
Checkin.objects.create(
position=op,
successful=False,
error_reason=Checkin.REASON_REVOKED,
**common_checkin_args
)
return Response({
'status': 'error',
'reason': Checkin.REASON_REVOKED,
'reason_explanation': None,
'require_attention': False,
'position': CheckinListOrderPositionSerializer(op, context=_make_context(context, revoked_matches[0].event)).data,
'list': MiniCheckinListSerializer(list_by_event[revoked_matches[0].event_id]).data,
}, status=400)
op_candidates = [media.linked_orderposition] + list(media.linked_orderposition.addons.all())
# 3. Handle the "multiple options found" case: Except for the unlikely case of a secret being also a valid primary
# key on the same list, we're probably dealing with the ``addon_match`` case here and need to figure out
@@ -634,6 +648,7 @@ def _redeem_process(*, checkinlists, raw_barcode, answers_data, datetime, force,
auth=auth,
type=checkin_type,
raw_barcode=raw_barcode_for_checkin,
raw_source_type=source_type,
from_revoked_secret=from_revoked_secret,
)
except RequiredQuestionsError as e:
@@ -812,6 +827,7 @@ class CheckinRPCRedeemView(views.APIView):
return _redeem_process(
checkinlists=s.validated_data['lists'],
raw_barcode=s.validated_data['secret'],
source_type=s.validated_data['source_type'],
answers_data=s.validated_data.get('answers'),
datetime=s.validated_data.get('datetime') or now(),
force=s.validated_data['force'],
+3 -2
View File
@@ -72,7 +72,7 @@ with scopes_disabled():
class Meta:
model = Event
fields = ['is_public', 'live', 'has_subevents']
fields = ['is_public', 'live', 'has_subevents', 'testmode']
def ends_after_qs(self, queryset, name, value):
expr = (
@@ -542,7 +542,8 @@ class EventSettingsView(views.APIView):
fname: {
'value': s.data[fname],
'label': getattr(field, '_label', fname),
'help_text': getattr(field, '_help_text', None)
'help_text': getattr(field, '_help_text', None),
'readonly': fname in s.readonly_fields,
} for fname, field in s.fields.items()
})
return Response(s.data)
+160
View File
@@ -0,0 +1,160 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from decimal import Decimal
import django_filters
from django.db import transaction
from django.db.models import OuterRef, Prefetch, Subquery, Sum
from django.db.models.functions import Coalesce
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import serializers, viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import MethodNotAllowed
from rest_framework.filters import OrderingFilter
from rest_framework.response import Response
from pretix.api.serializers.media import (
MediaLookupInputSerializer, ReusableMediaSerializer,
)
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
Checkin, GiftCard, GiftCardTransaction, OrderPosition, ReusableMedium,
)
from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class ReusableMediumFilter(FilterSet):
identifier = django_filters.CharFilter(field_name='identifier')
type = django_filters.CharFilter(field_name='type')
customer = django_filters.CharFilter(field_name='customer__identifier')
updated_since = django_filters.IsoDateTimeFilter(field_name='updated', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='created', lookup_expr='gte')
class Meta:
model = ReusableMedium
fields = ['identifier', 'type', 'active', 'customer', 'linked_orderposition', 'linked_giftcard']
class ReusableMediaViewSet(viewsets.ModelViewSet):
serializer_class = ReusableMediaSerializer
queryset = ReusableMedium.objects.none()
permission = 'can_manage_reusable_media'
write_permission = 'can_manage_reusable_media'
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('-updated', '-id')
ordering_fields = ('created', 'updated', 'identifier', 'type', 'id')
filterset_class = ReusableMediumFilter
def get_queryset(self):
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk')
).order_by().values('card').annotate(s=Sum('value')).values('s')
return self.request.organizer.reusable_media.prefetch_related(
Prefetch(
'linked_orderposition',
queryset=OrderPosition.objects.select_related(
'order', 'order__event', 'order__event__organizer', 'seat',
).prefetch_related(
Prefetch('checkins', queryset=Checkin.objects.all()),
'answers', 'answers__options', 'answers__question',
)
),
Prefetch(
'linked_giftcard',
queryset=GiftCard.objects.annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00'))
)
)
)
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)
inst.log_action(
'pretix.reusable_medium.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):
ReusableMedium.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
inst = serializer.save(identifier=serializer.instance.identifier, type=serializer.instance.type)
inst.log_action(
'pretix.reusable_medium.changed',
user=self.request.user,
auth=self.request.auth,
data=self.request.data,
)
return inst
def perform_destroy(self, instance):
raise MethodNotAllowed("Media cannot be deleted.")
@action(methods=["POST"], detail=False)
def lookup(self, request, *args, **kwargs):
s = MediaLookupInputSerializer(
data=request.data,
)
s.is_valid(raise_exception=True)
try:
m = ReusableMedium.objects.get(
type=s.validated_data["type"],
identifier=s.validated_data["identifier"],
organizer=request.organizer,
)
s = self.get_serializer(m)
return Response({"result": s.data})
except ReusableMedium.DoesNotExist:
mt = MEDIA_TYPES.get(s.validated_data["type"])
if mt:
m = mt.handle_unknown(request.organizer, s.validated_data["identifier"], request.user, request.auth)
if m:
s = self.get_serializer(m)
return Response({"result": s.data})
return Response({"result": None})
@scopes_disabled() # we are sure enough that get_queryset() is correct, so we save some perforamnce
def list(self, request, **kwargs):
date = serializers.DateTimeField().to_representation(now())
queryset = self.filter_queryset(self.get_queryset())
page = self.paginate_queryset(queryset)
if page is not None:
serializer = self.get_serializer(page, many=True)
resp = self.get_paginated_response(serializer.data)
resp['X-Page-Generated'] = date
return resp
serializer = self.get_serializer(queryset, many=True)
return Response(serializer.data, headers={'X-Page-Generated': date})
+3 -1
View File
@@ -34,6 +34,7 @@ from oauth2_provider.views import (
from pretix.api.models import OAuthApplication
from pretix.base.models import Organizer
from pretix.control.views.user import RecentAuthenticationRequiredMixin
logger = logging.getLogger(__name__)
@@ -54,7 +55,7 @@ class OAuthAllowForm(AllowForm):
del self.fields['organizers']
class AuthorizationView(BaseAuthorizationView):
class AuthorizationView(RecentAuthenticationRequiredMixin, BaseAuthorizationView):
template_name = "pretixcontrol/auth/oauth_authorization.html"
form_class = OAuthAllowForm
@@ -111,6 +112,7 @@ class AuthorizationView(BaseAuthorizationView):
self.request.user.log_action('pretix.user.oauth.authorized', user=self.request.user, data={
'application_id': application.pk,
'application_name': application.name,
'organizers': [o.pk for o in form.cleaned_data.get("organizers")] if form.cleaned_data.get("organizers") else []
})
return self.redirect(self.success_url, application)
+13 -9
View File
@@ -105,9 +105,9 @@ with scopes_disabled():
subevent_after = django_filters.IsoDateTimeFilter(method='subevent_after_qs')
subevent_before = django_filters.IsoDateTimeFilter(method='subevent_before_qs')
search = django_filters.CharFilter(method='search_qs')
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id')
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id')
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id')
item = django_filters.CharFilter(field_name='all_positions', lookup_expr='item_id', distinct=True)
variation = django_filters.CharFilter(field_name='all_positions', lookup_expr='variation_id', distinct=True)
subevent = django_filters.CharFilter(field_name='all_positions', lookup_expr='subevent_id', distinct=True)
class Meta:
model = Order
@@ -244,7 +244,8 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
Prefetch('meta_values', to_attr='meta_values_cached', queryset=SubEventMetaValue.objects.select_related('property'))
)),
Prefetch('addons', opq.select_related('item', 'variation', 'seat'))
Prefetch('addons', opq.select_related('item', 'variation', 'seat')),
'linked_media',
).select_related('seat', 'addon_to', 'addon_to__seat')
)
else:
@@ -639,13 +640,11 @@ class OrderViewSet(viewsets.ModelViewSet):
raise ValidationError(_('One of the selected products is not available in the selected country.'))
send_mail = serializer._send_mail
order = serializer.instance
if not order.pk:
# Simulation
# Simulation -- exit here
serializer = SimulatedOrderSerializer(order, context=serializer.context)
return Response(serializer.data, status=status.HTTP_201_CREATED)
else:
prefetch_related_objects([order], self._positions_prefetch(request))
serializer = OrderSerializer(order, context=serializer.context)
order.log_action(
'pretix.event.order.placed',
@@ -679,6 +678,10 @@ class OrderViewSet(viewsets.ModelViewSet):
if gen_invoice:
invoice = generate_invoice(order, trigger_pdf=True)
# Refresh serializer only after running signals
prefetch_related_objects([order], self._positions_prefetch(request))
serializer = OrderSerializer(order, context=serializer.context)
if send_mail:
free_flow = (
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
@@ -1005,6 +1008,7 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
Prefetch('meta_values', to_attr='meta_values_cached',
queryset=SubEventMetaValue.objects.select_related('property'))
)),
'linked_media',
Prefetch('order', self.request.event.orders.select_related('invoice_address').prefetch_related(
Prefetch(
'positions',
@@ -1507,7 +1511,7 @@ class PaymentViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
@action(detail=True, methods=['POST'])
def refund(self, request, **kwargs):
payment = self.get_object()
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
amount = serializers.DecimalField(max_digits=13, decimal_places=2).to_internal_value(
request.data.get('amount', str(payment.amount))
)
if 'mark_refunded' in request.data:
+3 -2
View File
@@ -197,7 +197,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
@transaction.atomic()
def transact(self, request, **kwargs):
gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
value = serializers.DecimalField(max_digits=13, decimal_places=2).to_internal_value(
request.data.get('value')
)
text = serializers.CharField(allow_blank=True, allow_null=True).to_internal_value(
@@ -457,7 +457,8 @@ class OrganizerSettingsView(views.APIView):
fname: {
'value': s.data[fname],
'label': getattr(field, '_label', fname),
'help_text': getattr(field, '_help_text', None)
'help_text': getattr(field, '_help_text', None),
'readonly': fname in s.readonly_fields,
} for fname, field in s.fields.items()
})
return Response(s.data)
+4
View File
@@ -256,6 +256,10 @@ def register_default_webhook_events(sender, **kwargs):
'pretix.event.order.refund.failed',
_('Refund of payment failed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.payment.confirmed',
_('Payment confirmed'),
),
ParametrizedOrderWebhookEvent(
'pretix.event.order.approved',
_('Order approved'),
+15 -11
View File
@@ -35,16 +35,14 @@ from django.dispatch import receiver
from django.template.loader import get_template
from django.utils.formats import date_format
from django.utils.timezone import now
from django.utils.translation import (
get_language, gettext_lazy as _, pgettext_lazy,
)
from django.utils.translation import get_language, gettext_lazy as _
from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyExpiresDate, LazyNumber,
)
from pretix.base.models import Event
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from pretix.base.signals import (
register_html_mail_renderers, register_mail_placeholders,
)
@@ -646,6 +644,10 @@ def base_placeholders(sender, **kwargs):
'attendee_name', ['position'], lambda position: position.attendee_name,
_('John Doe'),
),
SimpleFunctionalMailTextPlaceholder(
'positionid', ['position'], lambda position: str(position.positionid),
'1'
),
SimpleFunctionalMailTextPlaceholder(
'name', ['position_or_address'],
get_best_name,
@@ -659,6 +661,11 @@ def base_placeholders(sender, **kwargs):
else:
concatenation_for_salutation = name_scheme["concatenation"]
ph.append(SimpleFunctionalMailTextPlaceholder(
"name_for_salutation", ["waiting_list_entry"],
lambda waiting_list_entry: concatenation_for_salutation(waiting_list_entry.name_parts),
_("Mr Doe"),
))
ph.append(SimpleFunctionalMailTextPlaceholder(
"name_for_salutation", ["position_or_address"],
lambda position_or_address: concatenation_for_salutation(get_best_name(position_or_address, parts=True)),
@@ -668,6 +675,10 @@ def base_placeholders(sender, **kwargs):
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
ph.append(SimpleFunctionalMailTextPlaceholder(
'name_%s' % f, ['waiting_list_entry'], lambda waiting_list_entry, f=f: get_name_parts_localized(waiting_list_entry.name_parts, f),
name_scheme['sample'][f]
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'attendee_name_%s' % f, ['position'], lambda position, f=f: get_name_parts_localized(position.attendee_name_parts, f),
name_scheme['sample'][f]
@@ -689,10 +700,3 @@ def base_placeholders(sender, **kwargs):
))
return ph
def get_name_parts_localized(name_parts, key):
value = name_parts.get(key, "")
if key == "salutation":
return pgettext_lazy("person_name_salutation", value)
return value
+3 -1
View File
@@ -218,7 +218,9 @@ class ItemDataExporter(ListExporter):
yield row
def get_filename(self):
return '{}_products'.format(self.events.first().organizer.slug)
if self.is_multievent:
return '{}_products'.format(self.events.first().organizer.slug)
return '{}_products'.format(self.event.slug)
def prepare_xlsx_sheet(self, ws):
self.__ws = ws
+5 -5
View File
@@ -56,7 +56,7 @@ from pretix.base.models import (
)
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.services.quotas import QuotaAvailability
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.settings import PERSON_NAME_SCHEMES, get_name_parts_localized
from ...control.forms.filter import get_all_payment_providers
from ...helpers import GroupConcat
@@ -340,7 +340,7 @@ class OrderListExporter(MultiSheetListExporter):
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
get_name_parts_localized(order.invoice_address.name_parts, k)
)
row += [
order.invoice_address.street,
@@ -477,7 +477,7 @@ class OrderListExporter(MultiSheetListExporter):
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
get_name_parts_localized(order.invoice_address.name_parts, k)
)
row += [
order.invoice_address.street,
@@ -660,7 +660,7 @@ class OrderListExporter(MultiSheetListExporter):
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
op.attendee_name_parts.get(k, '')
get_name_parts_localized(op.attendee_name_parts, k)
)
row += [
op.attendee_email,
@@ -721,7 +721,7 @@ class OrderListExporter(MultiSheetListExporter):
if name_scheme and len(name_scheme['fields']) > 1:
for k, label, w in name_scheme['fields']:
row.append(
order.invoice_address.name_parts.get(k, '')
get_name_parts_localized(order.invoice_address.name_parts, k)
)
row += [
order.invoice_address.street,
+16
View File
@@ -36,11 +36,13 @@ import logging
import i18nfield.forms
from django import forms
from django.core.validators import URLValidator
from django.forms.models import ModelFormMetaclass
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
from formtools.wizard.views import SessionWizardView
from hierarkey.forms import HierarkeyForm
from i18nfield.strings import LazyI18nString
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
@@ -222,3 +224,17 @@ class SecretKeySettingsField(forms.CharField):
if value == SECRET_REDACTED:
return
return super().run_validators(value)
class I18nURLFormField(i18nfield.forms.I18nFormField):
def clean(self, value) -> LazyI18nString:
value = super().clean(value)
if not value:
return value
if isinstance(value.data, dict):
for v in value.data.values():
if v:
URLValidator()(v)
else:
URLValidator()(value.data)
return value
+3 -1
View File
@@ -719,7 +719,7 @@ class BaseQuestionsForm(forms.Form):
label = escape(q.question) # django-bootstrap3 calls mark_safe
required = q.required and not self.all_optional
if q.type == Question.TYPE_BOOLEAN:
if q.required:
if required:
# For some reason, django-bootstrap3 does not set the required attribute
# itself.
widget = forms.CheckboxInput(attrs={'required': 'required'})
@@ -747,12 +747,14 @@ class BaseQuestionsForm(forms.Form):
elif q.type == Question.TYPE_STRING:
field = forms.CharField(
label=label, required=required,
max_length=q.valid_string_length_max,
help_text=help_text,
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_TEXT:
field = forms.CharField(
label=label, required=required,
max_length=q.valid_string_length_max,
help_text=help_text,
widget=forms.Textarea,
initial=initial.answer if initial else None,
+8 -5
View File
@@ -148,6 +148,7 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
"""
stylesheet = StyleSheet1()
stylesheet.add(ParagraphStyle(name='Normal', fontName=self.font_regular, fontSize=10, leading=12))
stylesheet.add(ParagraphStyle(name='NormalRight', fontName=self.font_regular, fontSize=10, leading=12, alignment=TA_RIGHT))
stylesheet.add(ParagraphStyle(name='BoldInverseCenter', fontName=self.font_bold, fontSize=10, leading=12,
textColor=colors.white, alignment=TA_CENTER))
stylesheet.add(ParagraphStyle(name='InvoiceFrom', parent=stylesheet['Normal']))
@@ -610,8 +611,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
),
str(len(lines)),
localize(tax_rate) + " %",
money_filter(net_value * len(lines), self.invoice.event.currency),
money_filter(gross_value * len(lines), self.invoice.event.currency),
Paragraph(money_filter(net_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
))
else:
if len(lines) > 1:
@@ -625,7 +626,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
self.stylesheet['Normal']
),
str(len(lines)),
money_filter(gross_value * len(lines), self.invoice.event.currency),
Paragraph(money_filter(gross_value * len(lines), self.invoice.event.currency).replace('\xa0', ' '), self.stylesheet['NormalRight']),
))
taxvalue_map[tax_rate, tax_name] += (gross_value - net_value) * len(lines)
grossvalue_map[tax_rate, tax_name] += gross_value * len(lines)
@@ -633,12 +634,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if has_taxes:
tdata.append([
pgettext('invoice', 'Invoice total'), '', '', '', money_filter(total, self.invoice.event.currency)
pgettext('invoice', 'Invoice total'), '', '', '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.50, .05, .15, .15, .15)]
else:
tdata.append([
pgettext('invoice', 'Invoice total'), '', money_filter(total, self.invoice.event.currency)
pgettext('invoice', 'Invoice total'), '',
money_filter(total, self.invoice.event.currency)
])
colwidths = [a * doc.width for a in (.65, .20, .15)]
@@ -44,8 +44,8 @@ class Command(BaseCommand):
OrderPosition.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Sum('price')).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
output_field=models.DecimalField(decimal_places=2, max_digits=13)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=13)
),
position_cnt=Case(
When(Q(status__in=('e', 'c')) | Q(require_approval=True), then=Value(0)),
@@ -64,16 +64,16 @@ class Command(BaseCommand):
OrderFee.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Sum('value')).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
output_field=models.DecimalField(decimal_places=2, max_digits=13)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=13)
),
tx_total=Coalesce(
Subquery(
Transaction.objects.filter(
order=OuterRef('pk')
).order_by().values('order').annotate(p=Sum(F('price') * F('count'))).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
output_field=models.DecimalField(decimal_places=2, max_digits=13)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=13)
),
tx_cnt=Coalesce(
Subquery(
@@ -81,15 +81,15 @@ class Command(BaseCommand):
order=OuterRef('pk'),
item__isnull=False,
).order_by().values('order').annotate(p=Sum(F('count'))).values('p'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=10)
output_field=models.DecimalField(decimal_places=2, max_digits=13)
), Value(0), output_field=models.DecimalField(decimal_places=2, max_digits=13)
),
).annotate(
correct_total=Case(
When(Q(status=Order.STATUS_CANCELED) | Q(status=Order.STATUS_EXPIRED) | Q(require_approval=True),
then=Value(0)),
default=F('position_total') + F('fee_total'),
output_field=models.DecimalField(decimal_places=2, max_digits=10)
output_field=models.DecimalField(decimal_places=2, max_digits=13)
),
).exclude(
total=F('position_total') + F('fee_total'),
+116
View File
@@ -0,0 +1,116 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db import transaction
from django.utils.crypto import get_random_string
from django.utils.translation import gettext_lazy as _
class BaseMediaType:
medium_created_by_server = False
supports_orderposition = False
supports_giftcard = False
@property
def identifier(self):
raise NotImplementedError()
@property
def verbose_name(self):
raise NotImplementedError()
def generate_identifier(self, organizer):
if self.medium_created_by_server:
raise NotImplementedError()
else:
raise ValueError("Media type does not allow to generate identifier")
def is_active(self, organizer):
return organizer.settings.get(f'reusable_media_type_{self.identifier}', as_type=bool, default=False)
def handle_unknown(self, organizer, identifier, user, auth):
pass
def __str__(self):
return str(self.verbose_name)
class BarcodePlainMediaType(BaseMediaType):
identifier = 'barcode'
verbose_name = _('Barcode / QR-Code')
medium_created_by_server = True
supports_giftcard = False
supports_orderposition = True
def generate_identifier(self, organizer):
return get_random_string(
length=organizer.settings.reusable_media_type_barcode_identifier_length,
# Exclude o,0,1,i to avoid confusion with bad fonts/printers
# We use upper case to make collisions with ticket secrets less likely
allowed_chars='ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
)
class NfcUidMediaType(BaseMediaType):
identifier = 'nfc_uid'
verbose_name = _('NFC UID-based')
medium_created_by_server = False
supports_giftcard = True
supports_orderposition = False
def handle_unknown(self, organizer, identifier, user, auth):
from pretix.base.models import GiftCard, ReusableMedium
if organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard', as_type=bool):
if identifier.startswith("08"):
# Don't create gift cards for NFC UIDs that start with 08, which represents NFC cards that issue random
# UIDs on every read, so they won't be useful.
return
with transaction.atomic():
gc = GiftCard.objects.create(
issuer=organizer,
expires=organizer.default_gift_card_expiry,
currency=organizer.settings.get(f'reusable_media_type_{self.identifier}_autocreate_giftcard_currency'),
)
m = ReusableMedium.objects.create(
type=self.identifier,
identifier=identifier,
organizer=organizer,
active=True,
linked_giftcard=gc
)
m.log_action(
'pretix.reusable_medium.created.auto',
user=user, auth=auth,
)
gc.log_action(
'pretix.giftcards.created',
user=user, auth=auth,
)
return m
MEDIA_TYPES = {
m.identifier: m for m in [
BarcodePlainMediaType(),
NfcUidMediaType(),
]
}
@@ -0,0 +1,165 @@
# Generated by Django 3.2.18 on 2023-03-16 20:23
from decimal import Decimal
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0234_total_ordering'),
]
operations = [
migrations.AlterField(
model_name='cancellationrequest',
name='cancellation_fee',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='cartposition',
name='custom_price_input',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='cartposition',
name='line_price_gross',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='cartposition',
name='listed_price',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='cartposition',
name='price',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='cartposition',
name='price_after_voucher',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='discount',
name='condition_min_value',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=13),
),
migrations.AlterField(
model_name='giftcardtransaction',
name='value',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='invoice',
name='foreign_currency_rate',
field=models.DecimalField(decimal_places=4, max_digits=13, null=True),
),
migrations.AlterField(
model_name='invoiceline',
name='gross_value',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='invoiceline',
name='tax_value',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=13),
),
migrations.AlterField(
model_name='item',
name='default_price',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='item',
name='original_price',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='itembundle',
name='designated_price',
field=models.DecimalField(decimal_places=2, default=Decimal('0.00'), max_digits=13),
),
migrations.AlterField(
model_name='itemvariation',
name='default_price',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='itemvariation',
name='original_price',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='order',
name='total',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='orderfee',
name='tax_value',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='orderfee',
name='value',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='orderpayment',
name='amount',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='orderposition',
name='price',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='orderposition',
name='tax_value',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='orderposition',
name='voucher_budget_use',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='orderrefund',
name='amount',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='subeventitem',
name='price',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='subeventitemvariation',
name='price',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='transaction',
name='price',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='transaction',
name='tax_value',
field=models.DecimalField(decimal_places=2, max_digits=13),
),
migrations.AlterField(
model_name='voucher',
name='budget',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
migrations.AlterField(
model_name='voucher',
name='value',
field=models.DecimalField(decimal_places=2, max_digits=13, null=True),
),
]
@@ -0,0 +1,77 @@
# Generated by Django 3.2.18 on 2023-02-20 12:46
import django.core.serializers.json
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
def set_can_manage_reusable_media(apps, schema_editor):
Team = apps.get_model('pretixbase', 'Team')
Team.objects.filter(can_change_organizer_settings=True).update(can_manage_reusable_media=True)
Team.objects.filter(can_change_orders=True, all_events=True).update(can_manage_reusable_media=True)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0235_auto_20230316_2023'),
]
operations = [
migrations.AddField(
model_name='item',
name='media_policy',
field=models.CharField(max_length=16, null=True),
),
migrations.AddField(
model_name='item',
name='media_type',
field=models.CharField(max_length=100, null=True),
),
migrations.AddField(
model_name='team',
name='can_manage_reusable_media',
field=models.BooleanField(default=False),
),
migrations.CreateModel(
name='ReusableMedium',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('type', models.CharField(max_length=100)),
('identifier', models.CharField(max_length=200)),
('active', models.BooleanField(default=True)),
('expires', models.DateTimeField(blank=True, null=True)),
('info', models.JSONField(default=dict)),
('notes', models.TextField(null=True, blank=True)),
('customer', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL,
related_name='reusable_media', to='pretixbase.customer')),
('linked_giftcard',
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media',
to='pretixbase.giftcard')),
('linked_orderposition',
models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='linked_media',
to='pretixbase.orderposition')),
('organizer',
models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='reusable_media',
to='pretixbase.organizer')),
],
options={
'ordering': ('identifier', 'type', 'organizer'),
'unique_together': {('identifier', 'type', 'organizer')},
'index_together': {('identifier', 'type', 'organizer'), ('updated', 'id')},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.AddField(
model_name='checkin',
name='raw_source_type',
field=models.CharField(max_length=100, null=True),
),
migrations.RunPython(
set_can_manage_reusable_media,
migrations.RunPython.noop,
),
]
@@ -0,0 +1,18 @@
# Generated by Django 3.2.18 on 2023-04-05 10:03
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0236_reusable_media'),
]
operations = [
migrations.AddField(
model_name='question',
name='valid_string_length_max',
field=models.PositiveIntegerField(null=True),
),
]
+1
View File
@@ -40,6 +40,7 @@ from .items import (
SubEventItem, SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .media import ReusableMedium
from .memberships import Membership, MembershipType
from .notifications import NotificationSetting
from .orders import (
+12 -1
View File
@@ -44,6 +44,7 @@ from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.helpers import PostgresWindowFrame
@@ -55,7 +56,12 @@ class CheckinList(LoggedModel):
all_products = models.BooleanField(default=True, verbose_name=_("All products (including newly created ones)"))
limit_products = models.ManyToManyField('Item', verbose_name=_("Limit to products"), blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True,
verbose_name=pgettext_lazy('subevent', 'Date'), on_delete=models.CASCADE)
verbose_name=pgettext_lazy('subevent', 'Date'),
on_delete=models.CASCADE,
help_text=_('If you choose "all dates", tickets will be considered part of this list '
'and valid for check-in regardless of which date they are purchased for. '
'You can limit their validity through the advanced check-in rules, '
'though.'))
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
default=False,
help_text=_('With this option, people will be able to check in even if the '
@@ -372,6 +378,11 @@ class Checkin(models.Model):
# For "raw" scans where we do not know which position they belong to (e.g. scan of signed
# barcode that is not in database).
raw_barcode = models.TextField(null=True, blank=True)
raw_source_type = models.CharField(
max_length=100,
null=True, blank=True,
choices=[(k, v) for k, v in MEDIA_TYPES.items()],
)
raw_item = models.ForeignKey(
'pretixbase.Item',
related_name='checkins',
+2 -1
View File
@@ -142,6 +142,7 @@ class Customer(LoggedModel):
self.save()
self.all_logentries().update(data={}, shredded=True)
self.orders.all().update(customer=None)
self.reusable_media.all().update(customer=None)
self.memberships.all().update(attendee_name_parts=None)
self.attendee_profiles.all().delete()
self.invoice_addresses.all().delete()
@@ -221,7 +222,7 @@ class Customer(LoggedModel):
return salted_hmac(key_salt, payload).hexdigest()
def get_email_context(self):
from pretix.base.email import get_name_parts_localized
from pretix.base.settings import get_name_parts_localized
ctx = {
'name': self.name,
'organizer': self.organizer.name,
+2 -1
View File
@@ -180,7 +180,8 @@ class Device(LoggedModel):
'can_view_orders',
'can_change_orders',
'can_view_vouchers',
'can_manage_gift_cards'
'can_manage_gift_cards',
'can_manage_reusable_media',
}
def get_event_permission_set(self, organizer, event) -> set:
+1 -1
View File
@@ -116,7 +116,7 @@ class Discount(LoggedModel):
condition_min_value = models.DecimalField(
verbose_name=_('Minimum gross value of matching products'),
decimal_places=2,
max_digits=10,
max_digits=13,
default=Decimal('0.00'),
)
+1 -1
View File
@@ -129,7 +129,7 @@ class GiftCardTransaction(models.Model):
)
value = models.DecimalField(
decimal_places=2,
max_digits=10
max_digits=13
)
order = models.ForeignKey(
'Order',
+3 -3
View File
@@ -152,7 +152,7 @@ class Invoice(models.Model):
footer_text = models.TextField(blank=True)
foreign_currency_display = models.CharField(max_length=50, null=True, blank=True)
foreign_currency_rate = models.DecimalField(decimal_places=4, max_digits=10, null=True, blank=True)
foreign_currency_rate = models.DecimalField(decimal_places=4, max_digits=13, null=True, blank=True)
foreign_currency_rate_date = models.DateField(null=True, blank=True)
foreign_currency_source = models.CharField(max_length=100, null=True, blank=True)
@@ -347,8 +347,8 @@ class InvoiceLine(models.Model):
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
position = models.PositiveIntegerField(default=0)
description = models.TextField()
gross_value = models.DecimalField(max_digits=10, decimal_places=2)
tax_value = models.DecimalField(max_digits=10, decimal_places=2, default=Decimal('0.00'))
gross_value = models.DecimalField(max_digits=13, decimal_places=2)
tax_value = models.DecimalField(max_digits=13, decimal_places=2, default=Decimal('0.00'))
tax_rate = models.DecimalField(max_digits=7, decimal_places=2, default=Decimal('0.00'))
tax_name = models.CharField(max_length=190)
subevent = models.ForeignKey('SubEvent', null=True, blank=True, on_delete=models.PROTECT)
+73 -8
View File
@@ -45,7 +45,9 @@ import dateutil.parser
import pytz
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator, RegexValidator
from django.core.validators import (
MaxLengthValidator, MinValueValidator, RegexValidator,
)
from django.db import models
from django.db.models import Q
from django.utils import formats
@@ -64,6 +66,7 @@ from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice
from ...helpers.images import ImageSizeValidator
from ..media import MEDIA_TYPES
from .event import Event, SubEvent
@@ -164,7 +167,7 @@ class SubEventItem(models.Model):
"""
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
item = models.ForeignKey('Item', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
price = models.DecimalField(max_digits=13, decimal_places=2, null=True, blank=True)
disabled = models.BooleanField(default=False, verbose_name=_('Disable product for this date'))
available_from = models.DateTimeField(
verbose_name=_("Available from"),
@@ -220,7 +223,7 @@ class SubEventItemVariation(models.Model):
"""
subevent = models.ForeignKey('SubEvent', on_delete=models.CASCADE)
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE)
price = models.DecimalField(max_digits=7, decimal_places=2, null=True, blank=True)
price = models.DecimalField(max_digits=13, decimal_places=2, null=True, blank=True)
disabled = models.BooleanField(default=False, verbose_name=_('Disable product for this date'))
available_from = models.DateTimeField(
verbose_name=_("Available from"),
@@ -368,6 +371,16 @@ class Item(LoggedModel):
(VALIDITY_MODE_DYNAMIC, _('Dynamic validity')),
)
MEDIA_POLICY_REUSE = 'reuse'
MEDIA_POLICY_NEW = 'new'
MEDIA_POLICY_REUSE_OR_NEW = 'reuse_or_new'
MEDIA_POLICIES = (
(None, _("Don't use re-usable media, use regular one-off tickets")),
(MEDIA_POLICY_REUSE, _('Require an existing medium to be re-used')),
(MEDIA_POLICY_NEW, _('Require a previously unknown medium to be newly added')),
(MEDIA_POLICY_REUSE_OR_NEW, _('Require either an existing or a new medium to be used')),
)
objects = ItemQuerySetManager()
event = models.ForeignKey(
@@ -407,7 +420,7 @@ class Item(LoggedModel):
help_text=_("If this product has multiple variations, you can set different prices for each of the "
"variations. If a variation does not have a special price or if you do not have variations, "
"this price will be used."),
max_digits=7, decimal_places=2, null=True
max_digits=13, decimal_places=2, null=True
)
free_price = models.BooleanField(
default=False,
@@ -538,7 +551,7 @@ class Item(LoggedModel):
original_price = models.DecimalField(
verbose_name=_('Original price'),
blank=True, null=True,
max_digits=7, decimal_places=2,
max_digits=13, decimal_places=2,
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.')
)
@@ -630,6 +643,29 @@ class Item(LoggedModel):
help_text=_('The selected start date may only be this many days in the future.')
)
media_policy = models.CharField(
choices=MEDIA_POLICIES,
null=True, blank=True, max_length=16,
verbose_name=_('Reusable media policy'),
help_text=_(
'If this product should be stored on a re-usable physical medium, you can attach a physical media policy. '
'This is not required for regular tickets, which just use a one-time barcode, but only for products like '
'renewable season tickets or re-chargeable gift card wristbands. '
'This is an advanced feature that also requires specific configuration of ticketing and printing settings.'
)
)
media_type = models.CharField(
max_length=100,
null=True, blank=True,
choices=[(None, _("Don't use re-usable media, use regular one-off tickets"))] + [(k, v) for k, v in MEDIA_TYPES.items()],
verbose_name=_('Reusable media type'),
help_text=_(
'Select the type of physical medium that should be used for this product. Note that not all media types '
'support all types of products, and not all media types are supported across all sales channels or '
'check-in processes.'
)
)
# !!! Attention: If you add new fields here, also add them to the copying code in
# pretix/control/forms/item.py if applicable.
@@ -801,6 +837,24 @@ class Item(LoggedModel):
def has_variations(self):
return self.variations.exists()
@staticmethod
def clean_media_settings(event, media_policy, media_type, issue_giftcard):
if media_policy:
if not media_type:
raise ValidationError(_('If you select a reusable media policy, you also need to select a reusable '
'media type.'))
mt = MEDIA_TYPES[media_type]
if not mt.is_active(event.organizer):
raise ValidationError(_('The selected media type is not enabled in your organizer settings.'))
if not mt.supports_orderposition and not issue_giftcard:
raise ValidationError(_('The selected media type does not support usage for tickets currently.'))
if not mt.supports_giftcard and issue_giftcard:
raise ValidationError(_('The selected media type does not support usage for gift cards currently.'))
if issue_giftcard:
raise ValidationError(_('You currently cannot create gift cards with a reusable media policy. Instead, '
'gift cards for some reusable media types can be created or re-charged directly '
'at the POS.'))
@staticmethod
def clean_per_order(min_per_order, max_per_order):
if min_per_order is not None and max_per_order is not None:
@@ -952,14 +1006,14 @@ class ItemVariation(models.Model):
verbose_name=_("Position")
)
default_price = models.DecimalField(
decimal_places=2, max_digits=7,
decimal_places=2, max_digits=13,
null=True, blank=True,
verbose_name=_("Default price"),
)
original_price = models.DecimalField(
verbose_name=_('Original price'),
blank=True, null=True,
max_digits=7, decimal_places=2,
max_digits=13, decimal_places=2,
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.')
)
@@ -1304,7 +1358,7 @@ class ItemBundle(models.Model):
)
designated_price = models.DecimalField(
default=Decimal('0.00'), blank=True,
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
verbose_name=_('Designated price part'),
help_text=_('If set, it will be shown that this bundled item is responsible for the given value of the total '
'gross price. This might be important in cases of mixed taxation, but can be kept blank otherwise. This '
@@ -1488,6 +1542,11 @@ class Question(LoggedModel):
valid_datetime_max = models.DateTimeField(null=True, blank=True,
verbose_name=_('Maximum value'),
help_text=_('Currently not supported in our apps and during check-in'))
valid_string_length_max = models.PositiveIntegerField(null=True, blank=True,
verbose_name=_('Maximum length'),
help_text=_(
'Currently not supported in our apps and during check-in'
))
valid_file_portrait = models.BooleanField(
default=False,
verbose_name=_('Validate file to be a portrait'),
@@ -1634,6 +1693,12 @@ class Question(LoggedModel):
return answer
else:
raise ValidationError(_('Unknown country code.'))
elif self.type in (Question.TYPE_STRING, Question.TYPE_TEXT):
if self.valid_string_length_max is not None and len(answer) > self.valid_string_length_max:
raise ValidationError(MaxLengthValidator.message % {
'limit_value': self.valid_string_length_max,
'show_value': len(answer)
})
return answer
+125
View File
@@ -0,0 +1,125 @@
#
# This file is part of pretix (Community Edition).
#
# Copyright (C) 2014-2020 Raphael Michel and contributors
# Copyright (C) 2020-2021 rami.io GmbH and contributors
#
# This program is free software: you can redistribute it and/or modify it under the terms of the GNU Affero General
# Public License as published by the Free Software Foundation in version 3 of the License.
#
# ADDITIONAL TERMS APPLY: Pursuant to Section 7 of the GNU Affero General Public License, additional terms are
# applicable granting you additional permissions and placing additional restrictions on your usage of this software.
# Please refer to the pretix LICENSE file to obtain the full terms applicable to this work. If you did not receive
# this file, see <https://pretix.eu/about/en/license>.
#
# This program is distributed in the hope that it will be useful, but WITHOUT ANY WARRANTY; without even the implied
# warranty of MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more
# details.
#
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
from django.db import models
from django.db.models import Q
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import LoggedModel
from pretix.base.models.customers import Customer
from pretix.base.models.giftcards import GiftCard
from pretix.base.models.orders import OrderPosition
from pretix.base.models.organizer import Organizer
class ReusableMediumQuerySet(models.QuerySet):
def active(self):
return self.filter(
Q(expires__isnull=True) | Q(expires__gte=now()),
active=True,
)
class ReusableMediumQuerySetManager(ScopedManager(organizer='organizer').__class__):
def __init__(self):
super().__init__()
self._queryset_class = ReusableMediumQuerySet
def active(self):
return self.get_queryset().active()
class ReusableMedium(LoggedModel):
id = models.BigAutoField(primary_key=True)
created = models.DateTimeField(auto_now_add=True)
updated = models.DateTimeField(auto_now=True)
organizer = models.ForeignKey(
Organizer,
related_name='reusable_media',
on_delete=models.PROTECT
)
type = models.CharField(
verbose_name=pgettext_lazy('reusable_medium', 'Media type'),
choices=((k, v) for k, v in MEDIA_TYPES.items()),
max_length=100,
)
identifier = models.CharField(
max_length=200,
verbose_name=pgettext_lazy('reusable_medium', 'Identifier'),
)
active = models.BooleanField(
verbose_name=_('Active'),
default=True
)
expires = models.DateTimeField(
verbose_name=_('Expiration date'),
null=True, blank=True
)
customer = models.ForeignKey(
Customer,
null=True, blank=True,
related_name='reusable_media',
on_delete=models.SET_NULL,
verbose_name=_('Customer account'),
)
linked_orderposition = models.ForeignKey(
OrderPosition,
null=True, blank=True,
related_name='linked_media',
on_delete=models.SET_NULL,
verbose_name=_('Linked ticket'),
)
linked_giftcard = models.ForeignKey(
GiftCard,
null=True, blank=True,
related_name='linked_media',
on_delete=models.SET_NULL,
verbose_name=_('Linked gift card'),
)
info = models.JSONField(
default=dict
)
notes = models.TextField(verbose_name=_('Notes'), null=True, blank=True)
objects = ReusableMediumQuerySetManager()
@cached_property
def media_type(self):
return MEDIA_TYPES[self.type]
@property
def is_expired(self):
return self.expires and self.expires > now()
class Meta:
unique_together = (("identifier", "type", "organizer"),)
index_together = (("identifier", "type", "organizer"), ("updated", "id"))
ordering = "identifier", "type", "organizer"
+45 -21
View File
@@ -220,7 +220,7 @@ class Order(LockModel, LoggedModel):
verbose_name=_("Expiration date")
)
total = models.DecimalField(
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
verbose_name=_("Total amount")
)
comment = models.TextField(
@@ -403,8 +403,8 @@ class Order(LockModel, LoggedModel):
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
order=OuterRef('pk')
)
payment_sum_sq = Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
refund_sum_sq = Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10))
payment_sum_sq = Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=13))
refund_sum_sq = Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=13))
if sums:
qs = qs.annotate(
payment_sum=payment_sum_sq,
@@ -626,7 +626,10 @@ class Order(LockModel, LoggedModel):
has_checkin=Exists(Checkin.objects.filter(position_id=OuterRef('pk')))
).select_related('item').prefetch_related('issued_gift_cards')
)
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
if self.event.settings.change_allow_user_if_checked_in:
cancelable = all([op.item.allow_cancel for op in positions])
else:
cancelable = all([op.item.allow_cancel and not op.has_checkin for op in positions])
if not cancelable or not positions:
return False
for op in positions:
@@ -985,7 +988,7 @@ class Order(LockModel, LoggedModel):
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True,
attach_ical=False, attach_other_files: list=None):
attach_ical=False, attach_other_files: list=None, attach_cached_files: list=None):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
@@ -1030,7 +1033,7 @@ class Order(LockModel, LoggedModel):
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position, auto_email=auto_email, attach_ical=attach_ical,
attach_other_files=attach_other_files,
attach_other_files=attach_other_files, attach_cached_files=attach_cached_files,
)
except SendMailException:
raise
@@ -1313,7 +1316,7 @@ class AbstractPosition(models.Model):
on_delete=models.PROTECT
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
verbose_name=_("Price")
)
attendee_name_cached = models.CharField(
@@ -1545,7 +1548,7 @@ class OrderPayment(models.Model):
max_length=190, choices=PAYMENT_STATES
)
amount = models.DecimalField(
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
verbose_name=_("Amount")
)
order = models.ForeignKey(
@@ -1929,7 +1932,7 @@ class OrderRefund(models.Model):
max_length=190, choices=REFUND_SOURCES
)
amount = models.DecimalField(
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
verbose_name=_("Amount")
)
order = models.ForeignKey(
@@ -2078,7 +2081,7 @@ class OrderFee(models.Model):
)
value = models.DecimalField(
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
verbose_name=_("Value")
)
order = models.ForeignKey(
@@ -2102,7 +2105,7 @@ class OrderFee(models.Model):
null=True, blank=True
)
tax_value = models.DecimalField(
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
canceled = models.BooleanField(default=False)
@@ -2236,7 +2239,7 @@ class OrderPosition(AbstractPosition):
)
voucher_budget_use = models.DecimalField(
max_digits=10, decimal_places=2, null=True, blank=True,
max_digits=13, decimal_places=2, null=True, blank=True,
)
tax_rate = models.DecimalField(
@@ -2249,7 +2252,7 @@ class OrderPosition(AbstractPosition):
null=True, blank=True
)
tax_value = models.DecimalField(
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
@@ -2555,6 +2558,27 @@ class OrderPosition(AbstractPosition):
attach_tickets=True
)
@property
@scopes_disabled()
def attendee_change_allowed(self) -> bool:
"""
Returns whether or not this order can be changed by the attendee.
"""
from .items import ItemAddOn
if not self.event.settings.change_allow_attendee or not self.order.user_change_allowed:
return False
positions = list(
self.order.positions.filter(Q(pk=self.pk) | Q(addon_to_id=self.pk)).annotate(
has_variations=Exists(ItemVariation.objects.filter(item_id=OuterRef('item_id'))),
).select_related('item').prefetch_related('issued_gift_cards')
)
return (
(self.order.event.settings.change_allow_user_variation and any([op.has_variations for op in positions])) or
(self.order.event.settings.change_allow_user_addons and ItemAddOn.objects.filter(base_item_id__in=[op.item_id for op in positions]).exists())
)
class Transaction(models.Model):
"""
@@ -2649,7 +2673,7 @@ class Transaction(models.Model):
verbose_name=pgettext_lazy("subevent", "Date"),
)
price = models.DecimalField(
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
verbose_name=_("Price")
)
tax_rate = models.DecimalField(
@@ -2662,7 +2686,7 @@ class Transaction(models.Model):
null=True, blank=True
)
tax_value = models.DecimalField(
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
verbose_name=_('Tax value')
)
fee_type = models.CharField(
@@ -2740,19 +2764,19 @@ class CartPosition(AbstractPosition):
verbose_name=_('Tax rate')
)
listed_price = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
decimal_places=2, max_digits=13, null=True,
)
price_after_voucher = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
decimal_places=2, max_digits=13, null=True,
)
custom_price_input = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
decimal_places=2, max_digits=13, null=True,
)
custom_price_input_is_net = models.BooleanField(
default=False,
)
line_price_gross = models.DecimalField(
decimal_places=2, max_digits=10, null=True,
decimal_places=2, max_digits=13, null=True,
)
requested_valid_from = models.DateTimeField(
null=True,
@@ -2822,7 +2846,7 @@ class CartPosition(AbstractPosition):
# Migrate from pre-discounts position
if self.item.free_price and self.custom_price_input is None:
custom_price = self.price
if custom_price > 100000000:
if custom_price > 99_999_999_999:
raise ValueError('price_too_high')
self.custom_price_input = custom_price
self.custom_price_input_is_net = not False
@@ -3035,7 +3059,7 @@ class CachedCombinedTicket(models.Model):
class CancellationRequest(models.Model):
order = models.ForeignKey(Order, on_delete=models.CASCADE, related_name='cancellation_requests')
created = models.DateTimeField(auto_now_add=True)
cancellation_fee = models.DecimalField(max_digits=10, decimal_places=2)
cancellation_fee = models.DecimalField(max_digits=13, decimal_places=2)
refund_as_giftcard = models.BooleanField(default=False)
+6
View File
@@ -236,6 +236,8 @@ class Team(LoggedModel):
:type can_change_teams: bool
:param can_manage_customers: If ``True``, the members can view and change organizer-level customer accounts.
:type can_manage_customers: bool
:param can_manage_reusable_media: If ``True``, the members can view and change organizer-level reusable media.
:type can_manage_reusable_media: bool
:param can_change_organizer_settings: If ``True``, the members can change the settings of this organizer account.
:type can_change_organizer_settings: bool
:param can_change_event_settings: If ``True``, the members can change the settings of the associated events.
@@ -277,6 +279,10 @@ class Team(LoggedModel):
default=False,
verbose_name=_("Can manage customer accounts")
)
can_manage_reusable_media = models.BooleanField(
default=False,
verbose_name=_("Can manage reusable media")
)
can_manage_gift_cards = models.BooleanField(
default=False,
verbose_name=_("Can manage gift cards")
+3 -3
View File
@@ -213,7 +213,7 @@ class Voucher(LoggedModel):
verbose_name=_("Maximum discount budget"),
help_text=_("This is the maximum monetary amount that will be discounted using this voucher across all usages. "
"If this is sum reached, the voucher can no longer be used."),
decimal_places=2, max_digits=10,
decimal_places=2, max_digits=13,
null=True, blank=True
)
valid_until = models.DateTimeField(
@@ -243,7 +243,7 @@ class Voucher(LoggedModel):
)
value = models.DecimalField(
verbose_name=_("Voucher value"),
decimal_places=2, max_digits=10, null=True, blank=True,
decimal_places=2, max_digits=13, null=True, blank=True,
)
item = models.ForeignKey(
Item, related_name='vouchers',
@@ -599,7 +599,7 @@ class Voucher(LoggedModel):
Order.STATUS_PENDING
]
).order_by().values('voucher_id').annotate(s=Sum('voucher_budget_use')).values('s')
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=10, decimal_places=2)), Decimal('0.00')))
return qs.annotate(budget_used_orders=Coalesce(Subquery(opq, output_field=models.DecimalField(max_digits=13, decimal_places=2)), Decimal('0.00')))
def budget_used(self):
ops = OrderPosition.objects.filter(
+4 -1
View File
@@ -188,6 +188,9 @@ class WaitingListEntry(LoggedModel):
raise WaitingListException(_('This entry is anonymized and can no longer be used.'))
with transaction.atomic():
e = self.email
if self.name:
e += ' / ' + self.name
v = Voucher.objects.create(
event=self.event,
max_usages=1,
@@ -196,7 +199,7 @@ class WaitingListEntry(LoggedModel):
variation=self.variation,
tag='waiting-list',
comment=_('Automatically created from waiting list entry for {email}').format(
email=self.email
email=e
),
block_quota=True,
subevent=self.subevent,
+10
View File
@@ -1125,6 +1125,12 @@ class ManualPayment(BasePaymentProvider):
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{amount}', '{currency}', '{amount_with_currency}'])],
)),
('invoice_immediately',
forms.BooleanField(
label=_('Create an invoice for orders using bank transfer immediately if the event is otherwise '
'configured to create invoices after payment is completed.'),
required=False,
)),
] + list(super().settings_form_fields.items())
)
d.move_to_end('_enabled', last=False)
@@ -1164,6 +1170,10 @@ class ManualPayment(BasePaymentProvider):
format_map(self.settings.get('pending_description', as_type=LazyI18nString), self.format_map(payment.order, payment))
)
@property
def requires_invoice_immediately(self):
return self.settings.get('invoice_immediately', False, as_type=bool)
class OffsettingProvider(BasePaymentProvider):
is_enabled = True
+16 -7
View File
@@ -360,7 +360,7 @@ DEFAULT_VARIABLES = OrderedDict((
"label": _("List of Add-Ons"),
"editor_sample": _("Add-on 1\nAdd-on 2"),
"evaluate": lambda op, order, ev: "\n".join([
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
'{} - {}'.format(p.item.name, p.variation.value) if p.variation else str(p.item.name)
for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation')
@@ -455,6 +455,11 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
) if op.valid_until else ""
}),
("medium_identifier", {
"label": _("Reusable Medium ID"),
"editor_sample": "ABC1234DEF4567",
"evaluate": lambda op, order, ev: op.linked_media.all()[0].identifier if op.linked_media.all() else "",
}),
("seat", {
"label": _("Seat: Full name"),
"editor_sample": _("Ground floor, Row 3, Seat 4"),
@@ -583,10 +588,11 @@ def variables_from_questions(sender, *args, **kwargs):
def _get_attendee_name_part(key, op, order, ev):
name_parts = op.attendee_name_parts or (op.addon_to.attendee_name_parts if op.addon_to else {})
if isinstance(key, tuple):
parts = [_get_attendee_name_part(c[0], op, order, ev) for c in key if not (c[0] == 'salutation' and op.attendee_name_parts.get(c[0], '') == "Mx")]
parts = [_get_attendee_name_part(c[0], op, order, ev) for c in key if not (c[0] == 'salutation' and name_parts.get(c[0], '') == "Mx")]
return ' '.join(p for p in parts if p)
value = op.attendee_name_parts.get(key, '')
value = name_parts.get(key, '')
if key == 'salutation':
return pgettext('person_name_salutation', value)
return value
@@ -617,7 +623,7 @@ def get_variables(event):
v['attendee_name_for_salutation'] = {
'label': _("Attendee name for salutation"),
'editor_sample': _("Mr Doe"),
'evaluate': lambda op, order, ev: concatenation_for_salutation(op.attendee_name_parts or {})
'evaluate': lambda op, order, ev: concatenation_for_salutation(op.attendee_name_parts or (op.addon_to.attendee_name_parts if op.addon_to else {}))
}
for key, label, weight in scheme['fields']:
@@ -761,6 +767,9 @@ class Renderer:
else:
content = self._get_text_content(op, order, o)
if len(content) == 0:
return
level = 'H'
if len(content) > 32:
level = 'M'
@@ -777,16 +786,16 @@ class Renderer:
qr_y = float(o['bottom']) * mm
renderPDF.draw(d, canvas, qr_x, qr_y)
# Add QR content + PDF issuer as a hidden string (fully transparent & off page)
# Add QR content + PDF issuer as a hidden string (fully transparent & very very small)
# This helps automated processing of the PDF file by 3rd parties, e.g. when checking tickets for resale
data = {
"issuer": settings.SITE_URL,
o.get('content', 'secret'): content
}
canvas.saveState()
canvas.setFont('Open Sans', 1)
canvas.setFont('Open Sans', .01)
canvas.setFillColorRGB(0, 0, 0, 0)
canvas.drawString(-1000 * mm, -1000 * mm, json.dumps(data, sort_keys=True))
canvas.drawString(0 * mm, 0 * mm, json.dumps(data, sort_keys=True))
canvas.restoreState()
def _get_ev(self, op, order):
+39 -8
View File
@@ -31,6 +31,7 @@
# Unless required by applicable law or agreed to in writing, software distributed under the Apache License 2.0 is
# distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License.
import re
import uuid
from collections import Counter, defaultdict, namedtuple
from datetime import datetime, time, timedelta
@@ -38,6 +39,7 @@ from decimal import Decimal
from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django import forms
from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction
from django.db.models import Count, Exists, IntegerField, OuterRef, Q, Value
@@ -50,6 +52,7 @@ from django_scopes import scopes_disabled
from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemVariation, Seat,
SeatCategoryMapping, Voucher,
@@ -135,6 +138,7 @@ error_messages = {
'some_subevent_ended': gettext_lazy(
'The booking period for one of the events in your cart has ended. The affected '
'positions have been removed from your cart.'),
'price_not_a_number': gettext_lazy('The entered price is not a number.'),
'price_too_high': gettext_lazy('The entered price is to high.'),
'voucher_invalid': gettext_lazy('This voucher code is not known in our database.'),
'voucher_min_usages': gettext_lazy(
@@ -196,6 +200,8 @@ error_messages = {
'seat_multiple': gettext_lazy('You can not select the same seat multiple times.'),
'gift_card': gettext_lazy("You entered a gift card instead of a voucher. Gift cards can be entered later on when you're asked for your payment details."),
'country_blocked': gettext_lazy('One of the selected products is not available in the selected country.'),
'media_usage_not_implemented': gettext_lazy('The configuration of this product requires mapping to a physical '
'medium, which is currently not available online.'),
}
@@ -391,6 +397,13 @@ class CartManager:
if not op.item.is_available() or (op.variation and not op.variation.is_available()):
raise CartError(error_messages['unavailable'])
if op.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[op.item.media_type]
if not mt.medium_created_by_server:
raise CartError(error_messages['media_usage_not_implemented'])
elif op.item.media_policy == Item.MEDIA_POLICY_REUSE:
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):
raise CartError(error_messages['unavailable'])
@@ -725,9 +738,18 @@ class CartManager:
price_after_voucher = listed_price
custom_price = None
if item.free_price and i.get('price'):
custom_price = Decimal(str(i.get('price')).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
custom_price = re.sub('[^0-9.,]', '', str(i.get('price')))
if not custom_price:
raise CartError(error_messages['price_not_a_number'])
try:
custom_price = forms.DecimalField(localize=True).to_python(custom_price)
except:
try:
custom_price = Decimal(custom_price)
except:
raise CartError(error_messages['price_not_a_number'])
if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high'])
op = self.AddOperation(
count=i['count'],
@@ -840,9 +862,18 @@ class CartManager:
listed_price = get_listed_price(item, variation, cp.subevent)
custom_price = None
if item.free_price and a.get('price'):
custom_price = Decimal(str(a.get('price')).replace(",", "."))
if custom_price > 100000000:
raise ValueError('price_too_high')
custom_price = re.sub('[^0-9.,]', '', a.get('price'))
if not custom_price:
raise CartError(error_messages['price_not_a_number'])
try:
custom_price = forms.DecimalField(localize=True).to_python(custom_price)
except:
try:
custom_price = Decimal(custom_price)
except:
raise CartError(error_messages['price_not_a_number'])
if custom_price > 99_999_999_999:
raise CartError(error_messages['price_too_high'])
# Fix positions with wrong price (TODO: happens out-of-cartmanager-transaction and therefore a little hacky)
for ca in current_addons[cp][a['item'], a['variation']]:
@@ -1141,7 +1172,7 @@ class CartManager:
custom_price_input=op.custom_price_input,
custom_price_input_is_net=op.custom_price_input_is_net,
line_price_gross=line_price.gross,
tax_rate=line_price.tax,
tax_rate=line_price.rate,
price=line_price.gross,
)
if self.event.settings.attendee_names_asked:
@@ -1188,7 +1219,7 @@ class CartManager:
custom_price_input=b.custom_price_input,
custom_price_input_is_net=b.custom_price_input_is_net,
line_price_gross=bline_price.gross,
tax_rate=bline_price.tax,
tax_rate=bline_price.rate,
price=bline_price.gross,
is_bundled=True
))
+87 -56
View File
@@ -693,7 +693,7 @@ def _save_answers(op, answers, given_answers):
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None, canceled_supported=False, type=Checkin.TYPE_ENTRY,
raw_barcode=None, from_revoked_secret=False):
raw_barcode=None, raw_source_type=None, from_revoked_secret=False):
"""
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time.
@@ -714,40 +714,53 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
# !!!!!!!!!
dt = datetime or now()
force_used = False
if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING):
raise CheckInError(
_('This order position has been canceled.'),
'canceled' if canceled_supported else 'unpaid'
)
if force:
force_used = True
else:
raise CheckInError(
_('This order position has been canceled.'),
'canceled' if canceled_supported else 'unpaid'
)
if op.blocked:
raise CheckInError(
_('This ticket has been blocked.'), # todo provide reason
'blocked'
)
if force:
force_used = True
else:
raise CheckInError(
_('This ticket has been blocked.'), # todo provide reason
'blocked'
)
if type != Checkin.TYPE_EXIT and op.valid_from and op.valid_from > now():
raise CheckInError(
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
)
if force:
force_used = True
else:
raise CheckInError(
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket is only valid after {datetime}.').format(
datetime=date_format(op.valid_from, 'SHORT_DATETIME_FORMAT')
),
)
if type != Checkin.TYPE_EXIT and op.valid_until and op.valid_until < now():
raise CheckInError(
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
)
if force:
force_used = True
else:
raise CheckInError(
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
'invalid_time',
_('This ticket was only valid before {datetime}.').format(
datetime=date_format(op.valid_until, 'SHORT_DATETIME_FORMAT')
),
)
# Do this outside of transaction so it is saved even if the checkin fails for some other reason
checkin_questions = list(
@@ -770,40 +783,57 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
op = opqs.get(pk=op.pk)
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
raise CheckInError(
_('This order position has an invalid product for this check-in list.'),
'product'
)
elif clist.subevent_id and op.subevent_id != clist.subevent_id:
raise CheckInError(
_('This order position has an invalid date for this check-in list.'),
'product'
)
elif op.order.status != Order.STATUS_PAID and not force and op.order.require_approval:
raise CheckInError(
_('This order is not yet approved.'),
'unpaid'
)
elif op.order.status != Order.STATUS_PAID and not force and not op.order.valid_if_pending and not (
if force:
force_used = True
else:
raise CheckInError(
_('This order position has an invalid product for this check-in list.'),
'product'
)
if clist.subevent_id and op.subevent_id != clist.subevent_id:
if force:
force_used = True
else:
raise CheckInError(
_('This order position has an invalid date for this check-in list.'),
'product'
)
if op.order.status != Order.STATUS_PAID and op.order.require_approval:
if force:
force_used = True
else:
raise CheckInError(
_('This order is not yet approved.'),
'unpaid'
)
elif op.order.status != Order.STATUS_PAID and not op.order.valid_if_pending and not (
ignore_unpaid and clist.include_pending and op.order.status == Order.STATUS_PENDING
):
raise CheckInError(
_('This order is not marked as paid.'),
'unpaid'
)
if force:
force_used = True
else:
raise CheckInError(
_('This order is not marked as paid.'),
'unpaid'
)
if type == Checkin.TYPE_ENTRY and clist.rules and not force:
if type == Checkin.TYPE_ENTRY and clist.rules:
rule_data = LazyRuleVars(op, clist, dt)
logic = _get_logic_environment(op.subevent or clist.event)
if not logic.apply(clist.rules, rule_data):
reason = _logic_explain(clist.rules, op.subevent or clist.event, rule_data)
raise CheckInError(
_('Entry not permitted: {explanation}.').format(
explanation=reason
),
'rules',
reason=reason
)
if force:
force_used = True
else:
reason = _logic_explain(clist.rules, op.subevent or clist.event, rule_data)
raise CheckInError(
_('Entry not permitted: {explanation}.').format(
explanation=reason
),
'rules',
reason=reason
)
if require_answers and not force and questions_supported:
raise RequiredQuestionsError(
@@ -837,9 +867,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
device=device,
gate=device.gate if device else None,
nonce=nonce,
forced=force and (not entry_allowed or from_revoked_secret),
forced=force and (not entry_allowed or from_revoked_secret or force_used),
force_sent=force,
raw_barcode=raw_barcode,
raw_source_type=raw_source_type,
)
op.order.log_action('pretix.event.checkin', data={
'position': op.id,
+61 -4
View File
@@ -47,7 +47,8 @@ from django.core.cache import cache
from django.core.exceptions import ValidationError
from django.db import transaction
from django.db.models import (
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, Sum, Value,
Count, Exists, F, IntegerField, Max, Min, OuterRef, Q, QuerySet, Sum,
Value,
)
from django.db.models.functions import Coalesce, Greatest
from django.db.transaction import get_connection
@@ -61,6 +62,7 @@ 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.i18n import get_language_without_region, language
from pretix.base.media import MEDIA_TYPES
from pretix.base.models import (
CartPosition, Device, Event, GiftCard, Item, ItemVariation, Membership,
Order, OrderPayment, OrderPosition, Quota, Seat, SeatCategoryMapping, User,
@@ -188,6 +190,7 @@ error_messages = {
'min'
),
'addon_no_multi': gettext_lazy('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
'addon_already_checked_in': gettext_lazy('You cannot remove the position %(addon)s since it has already been checked in.'),
}
logger = logging.getLogger(__name__)
@@ -1607,6 +1610,7 @@ class OrderChangeManager:
override_tax_rate=new_rate)
self._totaldiff += new_tax.gross - pos.price
self._operations.append(self.PriceOperation(pos, new_tax, new_tax.gross - pos.price))
self._invoice_dirty = True
def cancel_fee(self, fee: OrderFee):
self._totaldiff -= fee.value
@@ -1701,7 +1705,19 @@ class OrderChangeManager:
for a in position.addons.all():
self._operations.append(self.SplitOperation(a))
def set_addons(self, addons):
def set_addons(self, addons, limit_main_positions=None):
"""
This is a convenience method to change the add-on products selected on an order. The input structure is similar
to CartManager.set_addons. It will automatically compute the correct operations to add, cancel, or change
positions on the order. Every existing add-on not in the input will be canceled. Availability of the
products is validated (with some exceptions).
:param addons: A list of dictionaries with the keys ``"addon_to"``, ``"item"``, ``"variation"`` (all ID values),
``"count"``, and ``"price"``.
:param limit_main_positions: By default, the method works on all methods of the order. If you set this to a
queryset or a list of positions, all other positions and their add-ons will be kept
untouched.
"""
if self._operations:
raise ValueError("Setting addons should be the first/only operation")
@@ -1713,7 +1729,13 @@ class OrderChangeManager:
quota_diff = Counter() # Quota -> Number of usages
available_categories = defaultdict(set) # OrderPos -> Category IDs to choose from
price_included = defaultdict(dict) # OrderPos -> CategoryID -> bool(price is included)
toplevel_op = self.order.positions.filter(
if isinstance(limit_main_positions, QuerySet):
toplevel_qs = limit_main_positions
elif limit_main_positions is not None:
toplevel_qs = self.order.positions.filter(pk__in=[p.pk for p in limit_main_positions])
else:
toplevel_qs = self.order.positions
toplevel_op = toplevel_qs.filter(
addon_to__isnull=True
).prefetch_related(
'addons', 'item__addons', 'item__addons__addon_category'
@@ -1877,6 +1899,12 @@ class OrderChangeManager:
for a in current_addons[cp][k][:current_num - input_num]:
if a.canceled:
continue
if a.checkins.exists():
raise OrderError(
error_messages['addon_already_checked_in'] % {
'addon': str(a.item.name),
}
)
self.cancel(a)
def _check_seats(self):
@@ -2516,7 +2544,7 @@ class OrderChangeManager:
positions_to_fake_cart[op.position].seat = op.seat
elif isinstance(op, self.MembershipOperation):
positions_to_fake_cart[op.position].used_membership = op.membership
elif isinstance(op, self.CancelOperation):
elif isinstance(op, self.CancelOperation) and op.position in positions_to_fake_cart:
fake_cart.remove(positions_to_fake_cart[op.position])
elif isinstance(op, self.AddOperation):
cp = CartPosition(
@@ -2884,3 +2912,32 @@ def signal_listener_issue_memberships(sender: Event, order: Order, **kwargs):
for p in order.positions.all():
if p.item.grant_membership_type_id:
create_membership(order.customer, p)
@receiver(order_placed, dispatch_uid="pretixbase_order_placed_media")
@receiver(order_changed, dispatch_uid="pretixbase_order_changed_media")
@transaction.atomic()
def signal_listener_issue_media(sender: Event, order: Order, **kwargs):
from pretix.base.models import ReusableMedium
for p in order.positions.all():
if p.item.media_policy in (Item.MEDIA_POLICY_NEW, Item.MEDIA_POLICY_REUSE_OR_NEW):
mt = MEDIA_TYPES[p.item.media_type]
if mt.medium_created_by_server and not p.linked_media.exists():
rm = ReusableMedium.objects.create(
organizer=sender.organizer,
type=p.item.media_type,
identifier=mt.generate_identifier(sender.organizer),
active=True,
customer=order.customer,
linked_orderposition=p,
)
rm.log_action(
'pretix.reusable_medium.created',
data={
'by_order': order.code,
'linked_orderposition': p.pk,
'active': True,
'customer': order.customer_id,
}
)
+13 -2
View File
@@ -19,9 +19,11 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>.
#
import re
from decimal import Decimal
from typing import List, Optional, Tuple
from django import forms
from django.db.models import Q
from django.utils.timezone import now
@@ -69,8 +71,17 @@ def get_price(item: Item, variation: ItemVariation = None,
subtract_from_gross=bundled_sum)
elif item.free_price and custom_price is not None and custom_price != "":
if not isinstance(custom_price, Decimal):
custom_price = Decimal(str(custom_price).replace(",", "."))
if custom_price > 100000000:
custom_price = re.sub('[^0-9.,]', '', str(custom_price))
if not custom_price:
raise ValueError('price_not_a_number')
try:
custom_price = forms.DecimalField(localize=True).to_python(custom_price)
except:
try:
custom_price = Decimal(custom_price)
except:
raise ValueError('price_not_a_number')
if custom_price > 99_999_999_999:
raise ValueError('price_too_high')
price = tax_rule.tax(price, invoice_address=invoice_address)
+3 -2
View File
@@ -112,7 +112,7 @@ def dictsum(*dicts) -> dict:
def order_overview(
event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None, fees=False,
admission_only=False, base_qs=None
admission_only=False, base_qs=None, base_fees_qs=None,
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related(
'category', # for re-grouping
@@ -233,7 +233,8 @@ def order_overview(
payment_items = []
if subevent is None and fees:
qs = OrderFee.all.filter(
qs = OrderFee.all if base_fees_qs is None else base_fees_qs
qs = qs.filter(
order__event=event
).annotate(
status=Case(
+145 -12
View File
@@ -63,7 +63,8 @@ from rest_framework import serializers
from pretix.api.serializers.fields import (
ListMultipleChoiceField, UploadedFileField,
)
from pretix.api.serializers.i18n import I18nField
from pretix.api.serializers.i18n import I18nField, I18nURLField
from pretix.base.forms import I18nURLFormField
from pretix.base.models.tax import VAT_ID_COUNTRIES, TaxRule
from pretix.base.reldate import (
RelativeDateField, RelativeDateTimeField, RelativeDateWrapper,
@@ -168,6 +169,84 @@ DEFAULTS = {
"was not logged in during the purchase.")
)
},
'reusable_media_active': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Activate re-usable media"),
help_text=_("The re-usable media feature allows you to connect tickets and gift cards with physical media "
"such as wristbands or chip cards that may be re-used for different tickets or gift cards "
"later.")
)
},
'reusable_media_type_barcode': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Active"),
)
},
'reusable_media_type_barcode_identifier_length': {
'default': 24,
'type': int,
'form_class': forms.IntegerField,
'serializer_class': serializers.IntegerField,
'serializer_kwargs': dict(
validators=[
MinValueValidator(12),
MaxValueValidator(64),
]
),
'form_kwargs': dict(
label=_('Length of barcodes'),
validators=[
MinValueValidator(12),
MaxValueValidator(64),
],
required=True,
widget=forms.NumberInput(
attrs={
'min': '12',
'max': '64',
},
),
)
},
'reusable_media_type_nfc_uid': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Active"),
)
},
'reusable_media_type_nfc_uid_autocreate_giftcard': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Automatically create a new gift card if a previously unknown chip is seen"),
)
},
'reusable_media_type_nfc_uid_autocreate_giftcard_currency': {
'default': 'EUR',
'type': str,
'form_class': forms.ChoiceField,
'serializer_class': serializers.ChoiceField,
'serializer_kwargs': dict(
choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES],
),
'form_kwargs': dict(
choices=[(c.alpha_3, c.alpha_3 + " - " + c.name) for c in settings.CURRENCIES],
label=_("Gift card currency"),
)
},
'max_items_per_order': {
'default': '10',
'type': int,
@@ -211,6 +290,8 @@ DEFAULTS = {
'system_question_order': {
'default': {},
'type': dict,
'serializer_class': serializers.DictField,
'serializer_kwargs': lambda: dict(read_only=True, allow_empty=True),
},
'attendee_names_asked': {
'default': 'True',
@@ -1396,6 +1477,19 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Hide all unavailable dates from calendar or list views"),
help_text=_("This option currently only affects the calendar of this event series, not the organizer-wide "
"calendar.")
)
},
'event_calendar_future_only': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Hide all past dates from calendar"),
help_text=_("This option currently only affects the calendar of this event series, not the organizer-wide "
"calendar.")
)
},
'allow_modifications_after_checkin': {
@@ -1470,6 +1564,32 @@ DEFAULTS = {
label=_("Do not allow changes after"),
)
},
'change_allow_user_if_checked_in': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Allow change even though the ticket has already been checked in"),
help_text=_("By default, order changes are disabled after any ticket in the order has been checked in. "
"If you check this box, this requirement is lifted. It is still not possible to remove an "
"add-on product that has already been checked in individually. Use with care, and preferably "
"only in combination with a limitation on price changes above."),
)
},
'change_allow_attendee': {
'default': 'False',
'type': bool,
'form_class': forms.BooleanField,
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Allow individual attendees to change their ticket"),
help_text=_("By default, only the person who ordered the tickets can make any changes. If you check this "
"box, individual attendees can also make changes. However, individual attendees can always "
"only make changes that do not change the total price of the order. Such changes can always "
"only be made by the main customer."),
)
},
'cancel_allow_user': {
'default': 'True',
'type': bool,
@@ -1485,7 +1605,7 @@ DEFAULTS = {
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
max_digits=13, decimal_places=2
),
'form_kwargs': dict(
label=_("Charge a fixed cancellation fee"),
@@ -1510,7 +1630,7 @@ DEFAULTS = {
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
max_digits=13, decimal_places=2
),
'form_kwargs': dict(
label=_("Charge a percentual cancellation fee"),
@@ -1544,7 +1664,7 @@ DEFAULTS = {
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
max_digits=13, decimal_places=2
),
'form_kwargs': dict(
label=_("Keep a fixed cancellation fee"),
@@ -1565,7 +1685,7 @@ DEFAULTS = {
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
max_digits=13, decimal_places=2
),
'form_kwargs': dict(
label=_("Keep a percentual cancellation fee"),
@@ -1604,10 +1724,10 @@ DEFAULTS = {
'form_class': forms.DecimalField,
'serializer_class': serializers.DecimalField,
'serializer_kwargs': dict(
max_digits=10, decimal_places=2
max_digits=13, decimal_places=2
),
'form_kwargs': dict(
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
label=_("Step size for reduction amount"),
help_text=_('By default, customers can choose an arbitrary amount for you to keep. If you set this to e.g. '
'10, they will only be able to choose values in increments of 10.')
@@ -1689,14 +1809,15 @@ DEFAULTS = {
},
'privacy_url': {
'default': None,
'type': str,
'form_class': forms.URLField,
'type': LazyI18nString,
'form_class': I18nURLFormField,
'form_kwargs': dict(
label=_("Privacy Policy URL"),
help_text=_("This should point e.g. to a part of your website that explains how you use data gathered in "
"your ticket shop."),
widget=I18nTextInput,
),
'serializer_class': serializers.URLField,
'serializer_class': I18nURLField,
},
'confirm_texts': {
'default': LazyI18nStringList(),
@@ -3010,6 +3131,13 @@ def concatenation_for_salutation(d):
return " ".join(filter(None, (salutation, title, given_name, family_name)))
def get_name_parts_localized(name_parts, key):
value = name_parts.get(key, "")
if key == "salutation":
return pgettext_lazy("person_name_salutation", value)
return value
PERSON_NAME_SCHEMES = OrderedDict([
('given_family', {
'fields': (
@@ -3243,7 +3371,7 @@ COUNTRIES_WITH_STATE_IN_ADDRESS = {
'CA': (['Province', 'Territory'], 'short'),
# 'CN': (['Province', 'Autonomous region', 'Munincipality'], 'long'),
'MY': (['State'], 'long'),
'MX': (['State', 'Federal District'], 'short'),
'MX': (['State', 'Federal district'], 'short'),
'US': (['State', 'Outlying area', 'District'], 'short'),
}
@@ -3366,7 +3494,12 @@ def validate_organizer_settings(organizer, settings_dict):
# organizer-settings either.
#
# N.B.: When actually fleshing out this stub, adding it to the OrganizerUpdateForm should be considered.
pass
"""
if settings_dict.get('reusable_media_type_ntag_pretix1') and settings_dict.get('reusable_media_type_nfc_uid'):
raise ValidationError({
'reusable_media_type_nfc_uid': _('This needs to be disabled if other NFC-based types are active.')
})
"""
def global_settings_object(holder):
+8 -4
View File
@@ -151,16 +151,19 @@ class BaseDataShredder:
def shred_log_fields(logentry, banlist=None, whitelist=None):
d = logentry.parsed_data
shredded = False
if whitelist:
for k, v in d.items():
if k not in whitelist:
d[k] = ''
shredded = True
elif banlist:
for f in banlist:
if f in d:
d[f] = ''
shredded = True
logentry.data = json.dumps(d)
logentry.shredded = True
logentry.shredded = logentry.shredded or shredded
logentry.save(update_fields=['data', 'shredded'])
@@ -380,9 +383,10 @@ class QuestionAnswerShredder(BaseDataShredder):
d = le.parsed_data
if 'data' in d:
for i, row in enumerate(d['data']):
for f in row:
if f not in ('attendee_name', 'attendee_email'):
d['data'][i][f] = ''
if isinstance(d['data'], list):
for f in row:
if f not in ('attendee_name', 'attendee_email'):
d['data'][i][f] = ''
le.data = json.dumps(d)
le.shredded = True
le.save(update_fields=['data', 'shredded'])
+57 -26
View File
@@ -39,7 +39,7 @@ from urllib.parse import urlencode, urlparse
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.exceptions import NON_FIELD_ERRORS, ValidationError
from django.core.validators import MaxValueValidator
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import (
@@ -457,7 +457,49 @@ class EventUpdateForm(I18nModelForm):
}
class EventSettingsForm(SettingsForm):
class EventSettingsValidationMixin:
def clean(self):
data = super().clean()
settings_dict = self.obj.settings.freeze()
settings_dict.update(data)
validate_event_settings(self.obj, settings_dict)
return data
def add_error(self, field, error):
# Copied from Django, but with improved handling for validation errors on fields that are not part of this form
if not isinstance(error, ValidationError):
error = ValidationError(error)
if hasattr(error, 'error_dict'):
if field is not None:
raise TypeError(
"The argument `field` must be `None` when the `error` "
"argument contains errors for multiple fields."
)
else:
error = error.error_dict
else:
error = {field or NON_FIELD_ERRORS: error.error_list}
for field, error_list in error.items():
if field != NON_FIELD_ERRORS and field not in self.fields:
field = NON_FIELD_ERRORS
for e in error_list:
e.message = _('A validation error has occurred on a setting that is not part of this form: {error}').format(error=e.message)
if field not in self.errors:
if field == NON_FIELD_ERRORS:
self._errors[field] = self.error_class(error_class='nonfield')
else:
self._errors[field] = self.error_class()
self._errors[field].extend(error_list)
if field in self.cleaned_data:
del self.cleaned_data[field]
class EventSettingsForm(EventSettingsValidationMixin, SettingsForm):
timezone = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Event timezone"),
@@ -511,6 +553,7 @@ class EventSettingsForm(SettingsForm):
'low_availability_percentage',
'event_list_type',
'event_list_available_only',
'event_calendar_future_only',
'frontpage_text',
'event_info_text',
'attendee_names_asked',
@@ -572,13 +615,8 @@ class EventSettingsForm(SettingsForm):
return data
def clean(self):
self.cleaned_data = self._resolve_virtual_keys_input(self.cleaned_data)
data = super().clean()
settings_dict = self.event.settings.freeze()
settings_dict.update(data)
data = self._resolve_virtual_keys_input(data)
validate_event_settings(self.event, data)
return data
def __init__(self, *args, **kwargs):
@@ -602,6 +640,7 @@ class EventSettingsForm(SettingsForm):
del self.fields['frontpage_subevent_ordering']
del self.fields['event_list_type']
del self.fields['event_list_available_only']
del self.fields['event_calendar_future_only']
# create "virtual" fields for better UX when editing <name>_asked and <name>_required fields
self.virtual_keys = []
@@ -688,6 +727,8 @@ class CancelSettingsForm(SettingsForm):
'change_allow_user_price',
'change_allow_user_until',
'change_allow_user_addons',
'change_allow_user_if_checked_in',
'change_allow_attendee',
]
def __init__(self, *args, **kwargs):
@@ -698,7 +739,7 @@ class CancelSettingsForm(SettingsForm):
).format(self.obj.settings.giftcard_expiry_years)
class PaymentSettingsForm(SettingsForm):
class PaymentSettingsForm(EventSettingsValidationMixin, SettingsForm):
auto_fields = [
'payment_term_mode',
'payment_term_days',
@@ -730,13 +771,6 @@ class PaymentSettingsForm(SettingsForm):
raise ValidationError(_("This field is required."))
return value
def clean(self):
data = super().clean()
settings_dict = self.obj.settings.freeze()
settings_dict.update(data)
validate_event_settings(self.obj, data)
return data
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['tax_rate_default'].queryset = self.obj.tax_rules.all()
@@ -784,7 +818,7 @@ class ProviderForm(SettingsForm):
return cleaned_data
class InvoiceSettingsForm(SettingsForm):
class InvoiceSettingsForm(EventSettingsValidationMixin, SettingsForm):
auto_fields = [
'invoice_address_asked',
@@ -859,13 +893,6 @@ class InvoiceSettingsForm(SettingsForm):
)
self.fields['invoice_numbers_counter_length'].validators.append(MaxValueValidator(15))
def clean(self):
data = super().clean()
settings_dict = self.obj.settings.freeze()
settings_dict.update(data)
validate_event_settings(self.obj, data)
return data
def contains_web_channel_validate(val):
if "web" not in val:
@@ -1213,6 +1240,7 @@ class MailSettingsForm(SettingsForm):
'mail_subject_order_canceled': ['event', 'order', 'comment'],
'mail_text_order_expire_warning': ['event', 'order'],
'mail_subject_order_expire_warning': ['event', 'order'],
'mail_text_order_pending_warning': ['event', 'order'],
'mail_subject_order_pending_warning': ['event', 'order'],
'mail_text_order_incomplete_payment': ['event', 'order', 'pending_sum'],
'mail_subject_order_incomplete_payment': ['event', 'order'],
@@ -1374,7 +1402,10 @@ class TaxRuleLineForm(I18nForm):
invoice_text = I18nFormField(
label=_('Text on invoice'),
required=False,
widget=I18nTextInput
widget=I18nTextInput,
widget_kwargs=dict(attrs={
'placeholder': _('Text on invoice'),
})
)
@@ -1564,7 +1595,7 @@ class QuickSetupProductForm(I18nForm):
)
default_price = forms.DecimalField(
label=_("Price (optional)"),
max_digits=7, decimal_places=2, required=False,
max_digits=13, decimal_places=2, required=False,
localize=True,
widget=forms.TextInput(
attrs={
+102 -3
View File
@@ -61,7 +61,7 @@ from pretix.base.models import (
SubEventMetaValue, Team, TeamAPIToken, TeamInvite, Voucher,
)
from pretix.base.signals import register_payment_providers
from pretix.control.forms.widgets import Select2
from pretix.control.forms.widgets import Select2, Select2ItemVarQuota
from pretix.control.signals import order_search_filter_q
from pretix.helpers.countries import CachedCountries
from pretix.helpers.database import (
@@ -383,8 +383,6 @@ class OrderFilterForm(FilterForm):
if fdata.get('ordering'):
qs = qs.order_by(*get_deterministic_ordering(Order, self.get_order_by()))
else:
qs = qs.order_by('-datetime', '-pk')
if fdata.get('provider'):
qs = qs.annotate(
@@ -571,6 +569,12 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
label=_('Sales channel'),
required=False,
)
checkin_attention = forms.NullBooleanField(
required=False,
widget=FilterNullBooleanSelect,
label=_('Requires special attention'),
help_text=_('Only matches orders with the attention checkbox set directly for the order, not based on the product.'),
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -691,6 +695,8 @@ class EventOrderExpertFilterForm(EventOrderFilterForm):
qs = qs.filter(total=fdata.get('total'))
if fdata.get('email_known_to_work') is not None:
qs = qs.filter(email_known_to_work=fdata.get('email_known_to_work'))
if fdata.get('checkin_attention') is not None:
qs = qs.filter(checkin_attention=fdata.get('checkin_attention'))
if fdata.get('locale'):
qs = qs.filter(locale=fdata.get('locale'))
if fdata.get('payment_sum_min') is not None:
@@ -1421,6 +1427,62 @@ class CustomerFilterForm(FilterForm):
return qs.distinct()
class ReusableMediaFilterForm(FilterForm):
orders = {
'type': 'type',
'identifier': 'identifier',
}
query = forms.CharField(
label=_('Search query'),
widget=forms.TextInput(attrs={
'placeholder': _('Search query'),
'autofocus': 'autofocus'
}),
required=False
)
status = forms.ChoiceField(
label=_('Status'),
required=False,
choices=(
('', _('All')),
('active', _('active')),
('disabled', _('disabled')),
('expired', _('expired')),
)
)
def __init__(self, *args, **kwargs):
kwargs.pop('request')
super().__init__(*args, **kwargs)
def filter_qs(self, qs):
fdata = self.cleaned_data
if fdata.get('query'):
query = fdata.get('query')
qs = qs.filter(
Q(identifier__icontains=query)
| Q(customer__identifier__icontains=query)
| Q(customer__external_identifier__istartswith=query)
| Q(linked_orderposition__order__code__icontains=query)
| Q(linked_giftcard__secret__icontains=query)
)
if fdata.get('status') == 'active':
qs = qs.filter(Q(expires__gt=now()) | Q(expires__isnull=False), active=True)
elif fdata.get('status') == 'disabled':
qs = qs.filter(active=False)
elif fdata.get('status') == 'expired':
qs = qs.filter(expires__lte=now())
if fdata.get('ordering'):
qs = qs.order_by(self.get_order_by())
else:
qs = qs.order_by("identifier", "type", "organizer")
return qs.distinct()
class TeamFilterForm(FilterForm):
orders = {
'name': 'name',
@@ -2204,7 +2266,30 @@ class CheckinFilterForm(FilterForm):
super().__init__(*args, **kwargs)
self.fields['device'].queryset = self.event.organizer.devices.all().order_by('device_id')
self.fields['device'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.devices.select2', kwargs={
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('All devices'),
}
)
self.fields['device'].widget.choices = self.fields['device'].choices
self.fields['device'].label = _('Device')
self.fields['gate'].queryset = self.event.organizer.gates.all()
self.fields['gate'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.gates.select2', kwargs={
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('All gates'),
}
)
self.fields['gate'].widget.choices = self.fields['gate'].choices
self.fields['gate'].label = _('Gate')
self.fields['checkin_list'].queryset = self.event.checkin_lists.all()
self.fields['checkin_list'].widget = Select2(
@@ -2231,6 +2316,20 @@ class CheckinFilterForm(FilterForm):
choices.append((str(i.pk), str(i)))
self.fields['itemvar'].choices = choices
self.fields['itemvar'].choices = choices
self.fields['itemvar'].widget = Select2ItemVarQuota(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:event.items.itemvar.select2', kwargs={
'event': self.event.slug,
'organizer': self.event.organizer.slug,
}),
'data-placeholder': _('All products')
}
)
self.fields['itemvar'].required = False
self.fields['itemvar'].widget.choices = self.fields['itemvar'].choices
def filter_qs(self, qs):
fdata = self.cleaned_data
+11
View File
@@ -168,6 +168,7 @@ class QuestionForm(I18nModelForm):
'valid_date_min',
'valid_date_max',
'valid_file_portrait',
'valid_string_length_max',
]
widgets = {
'valid_datetime_min': SplitDateTimePickerWidget(),
@@ -401,6 +402,8 @@ class ItemCreateForm(I18nModelForm):
'validity_dynamic_duration_months',
'validity_dynamic_start_choice',
'validity_dynamic_start_choice_day_limit',
'media_type',
'media_policy',
)
for f in fields:
setattr(self.instance, f, getattr(src, f))
@@ -592,6 +595,10 @@ class ItemUpdateForm(I18nModelForm):
del self.fields['grant_membership_duration_days']
del self.fields['grant_membership_duration_months']
if not self.event.settings.reusable_media_active:
del self.fields['media_type']
del self.fields['media_policy']
def clean(self):
d = super().clean()
if d['issue_giftcard']:
@@ -635,6 +642,8 @@ class ItemUpdateForm(I18nModelForm):
_("The start of validity must be before the end of validity.")
)
Item.clean_media_settings(self.event, d.get('media_policy'), d.get('media_type'), d.get('issue_giftcard'))
return d
def clean_picture(self):
@@ -693,6 +702,8 @@ class ItemUpdateForm(I18nModelForm):
'validity_dynamic_duration_months',
'validity_dynamic_start_choice',
'validity_dynamic_start_choice_day_limit',
'media_policy',
'media_type',
]
field_classes = {
'available_from': SplitDateTimeField,
+9 -9
View File
@@ -167,7 +167,7 @@ class CancelForm(ForceQuotaConfirmationForm):
)
cancellation_fee = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
localize=True,
label=_('Keep a cancellation fee of'),
help_text=_('If you keep a fee, all positions within this order will be canceled and the order will be reduced '
@@ -213,7 +213,7 @@ class MarkPaidForm(ConfirmPaymentForm):
)
amount = forms.DecimalField(
required=True,
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
localize=True,
label=_('Payment amount'),
)
@@ -318,7 +318,7 @@ class OrderPositionAddForm(forms.Form):
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
localize=True,
label=_('Gross price'),
help_text=_("Including taxes, if any. Keep empty for the product's default price")
@@ -444,7 +444,7 @@ class OrderPositionChangeForm(forms.Form):
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
localize=True,
label=_('New price (gross)')
)
@@ -566,7 +566,7 @@ class OrderPositionChangeForm(forms.Form):
class OrderFeeChangeForm(forms.Form):
value = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
localize=True,
label=_('New price (gross)')
)
@@ -731,7 +731,7 @@ class OrderRefundForm(forms.Form):
)
)
partial_amount = forms.DecimalField(
required=False, max_digits=10, decimal_places=2,
required=False, max_digits=13, decimal_places=2,
localize=True
)
@@ -820,18 +820,18 @@ class EventCancelForm(forms.Form):
)
keep_fee_fixed = forms.DecimalField(
label=_("Keep a fixed cancellation fee"),
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
required=False
)
keep_fee_per_ticket = forms.DecimalField(
label=_("Keep a fixed cancellation fee per ticket"),
help_text=_("Free tickets and add-on products are not counted"),
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
required=False
)
keep_fee_percentage = forms.DecimalField(
label=_("Keep a percentual cancellation fee"),
max_digits=10, decimal_places=2,
max_digits=13, decimal_places=2,
required=False
)
keep_fees = forms.MultipleChoiceField(
+149 -4
View File
@@ -41,10 +41,14 @@ from django.core.exceptions import ValidationError
from django.db.models import Q
from django.forms import inlineformset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.html import conditional_escape
from django.utils.safestring import mark_safe
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes.forms import SafeModelMultipleChoiceField
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from i18nfield.forms import (
I18nFormField, I18nFormSetMixin, I18nTextarea, I18nTextInput,
)
@@ -62,15 +66,18 @@ from pretix.base.forms.questions import (
from pretix.base.forms.widgets import SplitDateTimePickerWidget
from pretix.base.models import (
Customer, Device, EventMetaProperty, Gate, GiftCard, Membership,
MembershipType, Organizer, Team,
MembershipType, OrderPosition, Organizer, ReusableMedium, Team,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.organizer import OrganizerFooterLink
from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_organizer_settings,
)
from pretix.control.forms import ExtFileField, SplitDateTimeField
from pretix.control.forms.event import (
SafeEventMultipleChoiceField, multimail_validate,
)
from pretix.control.forms.widgets import Select2
from pretix.multidomain.models import KnownDomain
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -208,6 +215,7 @@ class TeamForm(forms.ModelForm):
fields = ['name', 'all_events', 'limit_events', 'can_create_events',
'can_change_teams', 'can_change_organizer_settings',
'can_manage_gift_cards', 'can_manage_customers',
'can_manage_reusable_media',
'can_change_event_settings', 'can_change_items',
'can_view_orders', 'can_change_orders', 'can_checkin_orders',
'can_view_vouchers', 'can_change_vouchers']
@@ -389,6 +397,12 @@ class OrganizerSettingsForm(SettingsForm):
'cookie_consent_dialog_text_secondary',
'cookie_consent_dialog_button_yes',
'cookie_consent_dialog_button_no',
'reusable_media_active',
'reusable_media_type_barcode',
'reusable_media_type_barcode_identifier_length',
'reusable_media_type_nfc_uid',
'reusable_media_type_nfc_uid_autocreate_giftcard',
'reusable_media_type_nfc_uid_autocreate_giftcard_currency',
]
organizer_logo_image = ExtFileField(
@@ -431,6 +445,26 @@ class OrganizerSettingsForm(SettingsForm):
))
for k, v in PERSON_NAME_TITLE_GROUPS.items()
]
self.fields['reusable_media_active'].label = mark_safe(
conditional_escape(self.fields['reusable_media_active'].label) +
' ' +
'<span class="label label-info">{}</span>'.format(_('experimental'))
)
self.fields['reusable_media_active'].help_text = mark_safe(
conditional_escape(self.fields['reusable_media_active'].help_text) +
' ' +
'<br/><span class="fa fa-flask"></span> ' +
_('This feature is currently in an experimental stage. It only supports very limited use cases and might '
'change at any point.')
)
def clean(self):
data = super().clean()
settings_dict = self.obj.settings.freeze()
settings_dict.update(data)
validate_organizer_settings(self.obj, data)
return data
class MailSettingsForm(SettingsForm):
@@ -578,7 +612,8 @@ class WebHookForm(forms.ModelForm):
class GiftCardCreateForm(forms.ModelForm):
value = forms.DecimalField(
label=_('Gift card value'),
min_value=Decimal('0.00')
min_value=Decimal('0.00'),
max_value=Decimal('99999999.99'),
)
def __init__(self, *args, **kwargs):
@@ -625,6 +660,116 @@ class GiftCardUpdateForm(forms.ModelForm):
}
class SafeOrderPositionChoiceField(forms.ModelChoiceField):
def __init__(self, queryset, **kwargs):
queryset = queryset.model.all.none()
super().__init__(queryset, **kwargs)
def label_from_instance(self, op):
return f'{op.order.code}-{op.positionid} ({str(op.item) + ((" - " + str(op.variation)) if op.variation else "")})'
class ReusableMediumUpdateForm(forms.ModelForm):
error_messages = {
'duplicate': _("An medium with this type and identifier is already registered."),
}
class Meta:
model = ReusableMedium
fields = ['active', 'expires', 'customer', 'linked_giftcard', 'linked_orderposition', 'notes']
field_classes = {
'expires': SplitDateTimeField,
'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
organizer = self.instance.organizer
self.fields['linked_orderposition'].queryset = OrderPosition.all.filter(order__event__organizer=organizer).all()
self.fields['linked_orderposition'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.ticket_select2', kwargs={
'organizer': organizer.slug,
}),
'data-placeholder': _('Ticket')
}
)
self.fields['linked_orderposition'].widget.choices = self.fields['linked_orderposition'].choices
self.fields['linked_orderposition'].required = False
self.fields['linked_giftcard'].queryset = organizer.issued_gift_cards.all()
self.fields['linked_giftcard'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.giftcards.select2', kwargs={
'organizer': organizer.slug,
}),
'data-placeholder': _('Gift card')
}
)
self.fields['linked_giftcard'].widget.choices = self.fields['linked_giftcard'].choices
self.fields['linked_giftcard'].required = False
if organizer.settings.customer_accounts:
self.fields['customer'].queryset = organizer.customers.all()
self.fields['customer'].widget = Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizer.customers.select2', kwargs={
'organizer': organizer.slug,
}),
'data-placeholder': _('Customer')
}
)
self.fields['customer'].widget.choices = self.fields['customer'].choices
self.fields['customer'].required = False
else:
del self.fields['customer']
def clean(self):
identifier = self.cleaned_data.get('identifier')
type = self.cleaned_data.get('type')
if identifier is not None and type is not None:
try:
self.instance.organizer.reusable_media.exclude(pk=self.instance.pk).get(
identifier=identifier,
type=type,
)
except ReusableMedium.DoesNotExist:
pass
else:
raise forms.ValidationError(
self.error_messages['duplicate'],
code='duplicate',
)
return self.cleaned_data
class ReusableMediumCreateForm(ReusableMediumUpdateForm):
class Meta:
model = ReusableMedium
fields = ['active', 'type', 'identifier', 'expires', 'linked_orderposition', 'linked_giftcard', 'customer', 'notes']
field_classes = {
'expires': SplitDateTimeField,
'customer': SafeModelChoiceField,
'linked_giftcard': SafeModelChoiceField,
'linked_orderposition': SafeOrderPositionChoiceField,
}
widgets = {
'expires': SplitDateTimePickerWidget,
}
class CustomerUpdateForm(forms.ModelForm):
error_messages = {
'duplicate': _("An account with this email address is already registered."),
+2
View File
@@ -350,6 +350,8 @@ class VoucherBulkForm(VoucherForm):
reader = csv.DictReader(StringIO(raw), dialect=dialect)
except csv.Error as e:
raise ValidationError(_('CSV parsing failed: {error}.').format(error=str(e)))
if len(reader.fieldnames) == 1 and ',' in reader.fieldnames[0]:
raise ValidationError(_('CSV input was not recognized to have multiple columns, maybe you have some invalid quoted field in your input.'))
if 'email' not in reader.fieldnames:
raise ValidationError(_('CSV input needs to contain a field with the header "{header}".').format(header="email"))
unknown_fields = [f for f in reader.fieldnames if f not in ('email', 'name', 'tag', 'number')]
+7
View File
@@ -362,6 +362,10 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.customer.anonymized': _('The account has been disabled and anonymized.'),
'pretix.customer.password.resetrequested': _('A new password has been requested.'),
'pretix.customer.password.set': _('A new password has been set.'),
'pretix.reusable_medium.created': _('The reusable medium has been created.'),
'pretix.reusable_medium.created.auto': _('The reusable medium has been created automatically.'),
'pretix.reusable_medium.changed': _('The reusable medium has been changed.'),
'pretix.reusable_medium.linked_orderposition.changed': _('The medium has been connected to a new ticket.'),
'pretix.email.error': _('Sending of an email has failed.'),
'pretix.event.comment': _('The event\'s internal comment has been updated.'),
'pretix.event.canceled': _('The event has been canceled.'),
@@ -470,6 +474,7 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.voucher.redeemed': _('The voucher has been redeemed in order {order_code}.'),
'pretix.event.item.added': _('The product has been created.'),
'pretix.event.item.changed': _('The product has been changed.'),
'pretix.event.item.reordered': _('The product has been reordered.'),
'pretix.event.item.deleted': _('The product has been deleted.'),
'pretix.event.item.variation.added': _('The variation "{value}" has been created.'),
'pretix.event.item.variation.deleted': _('The variation "{value}" has been deleted.'),
@@ -488,9 +493,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.category.added': _('The category has been added.'),
'pretix.event.category.deleted': _('The category has been deleted.'),
'pretix.event.category.changed': _('The category has been changed.'),
'pretix.event.category.reordered': _('The category has been reordered.'),
'pretix.event.question.added': _('The question has been added.'),
'pretix.event.question.deleted': _('The question has been deleted.'),
'pretix.event.question.changed': _('The question has been changed.'),
'pretix.event.question.reordered': _('The question has been reordered.'),
'pretix.event.discount.added': _('The discount has been added.'),
'pretix.event.discount.deleted': _('The discount has been deleted.'),
'pretix.event.discount.changed': _('The discount has been changed.'),
+1 -1
View File
@@ -112,7 +112,7 @@ class PermissionMiddleware:
url = resolve(request.path_info)
url_name = url.url_name
if not request.path.startswith(get_script_prefix() + 'control'):
if not request.path.startswith(get_script_prefix() + 'control') and not (url.namespace.startswith("api-") and url_name == "authorize"):
# This middleware should only touch the /control subpath
return self.get_response(request)
+10
View File
@@ -578,6 +578,16 @@ def get_organizer_navigation(request):
'children': children,
})
if request.organizer.settings.reusable_media_active:
nav.append({
'label': _('Reusable media'),
'url': reverse('control:organizer.reusable_media', kwargs={
'organizer': request.organizer.slug
}),
'icon': 'key',
'active': 'organizer.reusable_medi' in url.url_name,
})
if 'can_change_organizer_settings' in request.orgapermset:
nav.append({
'label': _('Devices'),
@@ -46,4 +46,5 @@
{% endif %}
</div>
</form>
<!-- pretix-login-marker -->{# marker required for ajax calls to detect that user session is over #}
{% endblock %}
@@ -56,7 +56,7 @@
</p>
</div>
{% else %}
<div class="table-responsive">
<span class="table-responsive">
<table class="table table-hover table-quotas">
<thead>
<tr>
@@ -146,6 +146,10 @@
<span class="fa fa-qrcode fa-fw"></span>
<span title="{{ c.raw_barcode }}">
{{ c.raw_barcode|slice:":16" }}{% if c.raw_barcode|length > 16 %}…{% endif %}
<button type="button" class="btn btn-xs btn-link btn-clipboard" data-clipboard-text="{{ c.raw_barcode }}">
<i class="fa fa-clipboard" aria-hidden="true"></i>
<span class="sr-only">{% trans "Copy to clipboard" %}</span>
</button>
</span>
{% if c.raw_item %}
<br>
@@ -67,6 +67,8 @@
{% bootstrap_field form.change_allow_user_addons layout="control" %}
{% bootstrap_field form.change_allow_user_until layout="control" %}
{% bootstrap_field form.change_allow_user_price layout="control" %}
{% bootstrap_field form.change_allow_user_if_checked_in layout="control" %}
{% bootstrap_field form.change_allow_attendee layout="control" %}
<div class="alert alert-info">
<p>
{% blocktrans trimmed %}
@@ -16,6 +16,42 @@
{% endif %}
</small>
</h1>
<div class="helper-space-below">
{% trans "Shop URL:" %}
<span id="shop_url" class="text-muted">{% abseventurl request.event "presale:event.index" %}</span>
<button type="button" class="btn btn-default btn-xs btn-clipboard js-only" data-clipboard-target="#shop_url">
<i class="fa fa-clipboard" aria-hidden="true"></i>
<span class="sr-only">{% trans "Copy to clipboard" %}</span>
</button>
<div class="btn-group helper-display-inline-block">
<button type="button" class="btn btn-default btn-xs dropdown-toggle" data-toggle="dropdown" title="{% trans "Create QR code" %}" aria-haspopup="true" aria-expanded="false">
<i class="fa fa-qrcode" aria-hidden="true"></i>
</button>
<ul class="dropdown-menu dropdown-menu-right">
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="png" %}" target="_blank" download>
{% blocktrans with filetype="PNG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="svg" %}" target="_blank" download>
{% blocktrans with filetype="SVG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="jpeg" %}" target="_blank" download>
{% blocktrans with filetype="JPG" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
<li>
<a href="{% url "control:event.qrcode" event=request.event.slug organizer=request.organizer.slug filetype="gif" %}" target="_blank" download>
{% blocktrans with filetype="GIF" %}Download QR code as {{ filetype }} image{% endblocktrans %}
</a>
</li>
</ul>
</div>
<div class="clearfix"></div>
</div>
{% if has_overpaid_orders %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
@@ -15,6 +15,7 @@
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% csrf_token %}
{% bootstrap_form_errors form %}
{% bootstrap_form_errors sform %}
<div class="tabbed-form">
<fieldset>
<legend>{% trans "Basics" %}</legend>
@@ -315,7 +316,10 @@
{% if sform.event_list_available_only %}
{% bootstrap_field sform.event_list_available_only layout="control" %}
{% endif %}
{% bootstrap_field sform.low_availability_percentage layout="control" %}
{% if sform.event_calendar_future_only %}
{% bootstrap_field sform.event_calendar_future_only layout="control" %}
{% endif %}
{% bootstrap_field sform.low_availability_percentage layout="control" addon_after="%" %}
{% url "control:organizer.edit" organizer=request.organizer.slug as org_url %}
{% propagated request.event org_url "meta_noindex" %}
@@ -4,6 +4,7 @@
{% load formset_tags %}
{% block inside %}
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">
{% bootstrap_form_errors form layout="control" %}
{% csrf_token %}
<div class="row">
<div class="col-xs-12 col-lg-10">
@@ -178,6 +179,12 @@
<fieldset>
<legend>{% trans "Tickets & Badges" %}</legend>
{% bootstrap_field form.generate_tickets layout="control" %}
{% if form.media_policy %}
{% bootstrap_field form.media_policy layout="control" %}
{% endif %}
{% if form.media_type %}
{% bootstrap_field form.media_type layout="control" %}
{% endif %}
{% for f in plugin_forms %}
{% if f.is_layouts %}
{% bootstrap_form f layout="control" %}
@@ -44,6 +44,9 @@
{% bootstrap_field form.valid_datetime_min layout="control" %}
{% bootstrap_field form.valid_datetime_max layout="control" %}
</div>
<div id="valid-string">
{% bootstrap_field form.valid_string_length_max layout="control" %}
</div>
<div id="valid-file">
{% bootstrap_field form.valid_file_portrait layout="control" %}
</div>
@@ -462,6 +462,14 @@
</dd>
</div>
{% endif %}
{% for m in line.linked_media.all %}
<div class="cart-icon-details">
<dd>
<span class="fa fa-key fa-fw" aria-hidden="true"></span>
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">{{ m.identifier }}</a> <span class="text-muted">({{ m.get_type_display }})</span>
</dd>
</div>
{% endfor %}
{% if not line.canceled %}
<div class="position-buttons">
{% if line.generate_ticket %}
@@ -200,6 +200,70 @@
{% bootstrap_field sform.cookie_consent_dialog_button_yes layout="control" %}
{% bootstrap_field sform.cookie_consent_dialog_button_no layout="control" %}
</fieldset>
<fieldset>
<legend>{% trans "Reusable media" %}</legend>
{% bootstrap_field sform.reusable_media_active layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_active.id_for_label }}">
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">{% trans "Barcode media" %}</h4>
</div>
<div class="panel-body">
<p class="help-block">
{% blocktrans trimmed %}
A "barcode medium" can be any printed or digital representation of a barcode.
The medium will initially be created through the sale of a product that has a
media policy requiring such a medium as well as a ticket or badge layout that
includes the "Reusable Medium ID" as a QR code. Later, the same barcode may
be re-used during the sale of a different product.
{% endblocktrans %}
{% blocktrans trimmed %}
Barcode media can currently only be connected to tickets.
{% endblocktrans %}
{% blocktrans trimmed %}
This subsequent reuse of the barcode is currently only supported during POS sales.
{% endblocktrans %}
</p>
{% bootstrap_field sform.reusable_media_type_barcode layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_type_barcode.id_for_label }}">
{% bootstrap_field sform.reusable_media_type_barcode_identifier_length layout="control" %}
</div>
</div>
</div>
<div class="panel panel-default">
<div class="panel-heading">
<h4 class="panel-title">{% trans "NFC UID-based" %}</h4>
</div>
<div class="panel-body">
<p class="help-block">
{% blocktrans trimmed %}
This medium type can work with almost any type of NFC chip. With this
option, only the UID of the NFC chip is used for identification.
{% endblocktrans %}
{% blocktrans trimmed %}
NFC media can currently only be connected to gift cards.
{% endblocktrans %}
</p>
<p class="help-block">
<span class="fa fa-warning text-warning"></span>
{% blocktrans trimmed %}
This method does not provide a high level of protection against abuse since it
is possible for malicious users to clone someone's chip with the same UID.
{% endblocktrans %}
</p>
{% bootstrap_field sform.reusable_media_type_nfc_uid layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid.id_for_label }}">
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard layout="control" %}
<div data-display-dependency="#{{ sform.reusable_media_type_nfc_uid_autocreate_giftcard.id_for_label }}">
{% bootstrap_field sform.reusable_media_type_nfc_uid_autocreate_giftcard_currency layout="control" %}
</div>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Invoices" %}</legend>
{% bootstrap_field sform.invoice_regenerate_allowed layout="control" %}
@@ -0,0 +1,113 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load urlreplace %}
{% load bootstrap3 %}
{% load money %}
{% block title %}{% trans "Reusable media" %}{% endblock %}
{% block inner %}
<h1>
{% trans "Reusable media" %}
</h1>
{% if media|length == 0 and not filter_form.filtered %}
<div class="empty-collection">
<p>
{% blocktrans trimmed %}
No media have been created yet.
{% endblocktrans %}
</p>
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
class="btn btn-primary btn-lg"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
</div>
{% else %}
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">{% trans "Filter" %}</h3>
</div>
<form class="panel-body filter-form" action="" method="get">
<div class="row">
<div class="col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.query layout='inline' %}
</div>
<div class="col-md-6 col-sm-6 col-xs-12">
{% bootstrap_field filter_form.status layout='inline' %}
</div>
</div>
<div class="text-right">
<button class="btn btn-primary btn-lg" type="submit">
<span class="fa fa-filter"></span>
{% trans "Filter" %}
</button>
</div>
</form>
</div>
<p>
<a href="{% url "control:organizer.reusable_medium.create" organizer=request.organizer.slug %}"
class="btn btn-default"><i class="fa fa-plus"></i> {% trans "Create a new medium" %}</a>
</p>
<div class="table-responsive">
<table class="table table-condensed table-hover">
<thead>
<tr>
<th>{% trans "Identifier" context "reusable_media" %}
<a href="?{% url_replace request 'ordering' '-identifier' %}"><i
class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'identifier' %}"><i class="fa fa-caret-up"></i></a>
</th>
<th>{% trans "Media type" context "reusable_media" %}
<a href="?{% url_replace request 'ordering' '-email' %}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'email' %}"><i class="fa fa-caret-up"></i></a></th>
<th>{% trans "Connections" context "reusable_media" %}</th>
<th></th>
</tr>
</thead>
<tbody>
{% for m in media %}
<tr>
<td>
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}">
{% if not m.active %}<strike>{% endif %}
<strong>{{ m.identifier }}</strong>
{% if not m.active %}</strike>{% endif %}
</a>
</td>
<td>
{{ m.get_type_display }}
</td>
<td>
{% if m.customer %}
<span class="helper-display-block">
<span class="fa fa-user fa-fw"></span>
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=m.customer.identifier %}">
{{ m.customer }}
</a>
</span>
{% endif %}
{% if m.linked_orderposition %}
<span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=m.linked_orderposition.order.event.slug organizer=request.organizer.slug code=m.linked_orderposition.order.code %}">
{{ m.linked_orderposition.order.code }}</a>-{{ m.linked_orderposition.positionid }}
</span>
{% endif %}
{% if m.linked_giftcard %}
<span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span>
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=m.linked_giftcard.id %}">
{{ m.linked_giftcard.secret }}</a>
</span>
{% endif %}
</td>
<td class="text-right">
<a href="{% url "control:organizer.reusable_medium" organizer=request.organizer.slug pk=m.pk %}"
class="btn btn-default btn-sm" data-toggle="tooltip" title="{% trans "Details" %}">
<i class="fa fa-eye"></i>
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endblock %}
@@ -0,0 +1,93 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load money %}
{% block title %}
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
{% endblock %}
{% block inner %}
<h1>
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
</h1>
<div class="row">
<div class="col-md-10 col-xs-12">
<div class="panel panel-primary items">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Details" %}
</h3>
</div>
<div class="panel-body">
<form action="" method="post">
{% csrf_token %}
<dl class="dl-horizontal">
<dt>{% trans "Media type" context "reusable_media" %}</dt>
<dd>{{ medium.get_type_display }}</dd>
<dt>{% trans "Identifier" context "reusable_media" %}</dt>
<dd><code>{{ medium.identifier }}</code></dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if not medium.active %}
{% trans "disabled" %}
{% elif medium.is_expired %}
{% trans "expired" %}
{% else %}
{% trans "active" %}
{% endif %}
</dd>
<dt>{% trans "Connections" context "reusable_media" %}</dt>
<dd>
{% if medium.customer %}
<span class="helper-display-block">
<span class="fa fa-user fa-fw"></span>
<a href="{% url "control:organizer.customer" organizer=request.organizer.slug customer=medium.customer.identifier %}">
{{ medium.customer }}
</a>
</span>
{% endif %}
{% if medium.linked_orderposition %}
<span class="helper-display-block">
<span class="fa fa-ticket fa-fw"></span>
<a href="{% url "control:event.order" event=medium.linked_orderposition.order.event.slug organizer=request.organizer.slug code=medium.linked_orderposition.order.code %}">
{{ medium.linked_orderposition.order.code }}</a>-{{ medium.linked_orderposition.positionid }}
</span>
{% endif %}
{% if medium.linked_giftcard %}
<span class="helper-display-block">
<span class="fa fa-credit-card fa-fw"></span>
<a href="{% url "control:organizer.giftcard" organizer=request.organizer.slug giftcard=medium.linked_giftcard.id %}">
{{ medium.linked_giftcard.secret }}</a>
</span>
{% endif %}
</dd>
{% if medium.notes %}
<dt>{% trans "Notes" %}</dt>
<dd>{{ medium.notes }}</dd>
{% endif %}
</dl>
</form>
<div class="text-right">
<a href="{% url "control:organizer.reusable_medium.edit" organizer=request.organizer.slug pk=medium.pk %}"
class="btn btn-default">
<i class="fa fa-edit"></i> {% trans "Edit" %}
</a>
</div>
</div>
</div>
</div>
<div class="col-md-2 col-xs-12">
<div class="panel panel-default">
<div class="panel-heading">
<h3 class="panel-title">
{% trans "Medium history" context "reusable_media" %}
</h3>
</div>
{% include "pretixcontrol/includes/logs.html" with obj=medium %}
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,32 @@
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}
{% if not medium.pk %}
{% trans "New medium" context "reusable_media" %}
{% else %}
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
{% endif %}
{% endblock %}
{% block inner %}
<h1>
{% if not medium.pk %}
{% trans "New medium" context "reusable_media" %}
{% else %}
{% blocktrans trimmed with id=medium.identifier context "reusable_media" %}
Medium {{ id }}
{% endblocktrans %}
{% endif %}
</h1>
<form class="form-horizontal" action="" method="post">
{% csrf_token %}
{% bootstrap_form form layout="control" %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
{% trans "Save" %}
</button>
</div>
</form>
{% endblock %}
@@ -24,6 +24,7 @@
{% bootstrap_field form.can_create_events layout="control" %}
{% bootstrap_field form.can_manage_gift_cards layout="control" %}
{% bootstrap_field form.can_manage_customers layout="control" %}
{% bootstrap_field form.can_manage_reusable_media layout="control" %}
{% bootstrap_field form.can_change_teams layout="control" %}
{% bootstrap_field form.can_change_organizer_settings layout="control" %}
</fieldset>
@@ -174,6 +174,14 @@
</span>
</div>
<div class="panel-body" id="toolbox-body">
{% if name %}
<div class="row control-group pdf-info">
<div class="col-sm-12">
<label>{% trans "Layout name" %}</label><br>
<input type="text" id="pdf-info-name" class="input-block-level form-control" name="name" value="{{ name }}">
</div>
</div>
{% endif %}
<div class="row control-group pdf-info">
<div class="col-sm-6">
<label>{% trans "Width (mm)" %}</label><br>
@@ -455,6 +463,7 @@
{% trans "Preview" %}
</button>
<button type="submit" class="btn btn-primary btn-save" id="editor-save">
<span class="fa fa-fw fa-save"></span>
{% trans "Save" %}
</button>
</div>
@@ -515,6 +515,16 @@
</fieldset>
<fieldset>
<legend>{% trans "Check-in lists" %}</legend>
<p class="help-block">
{% blocktrans trimmed %}
You can choose to either add one or more check-in lists for every date in your series individually,
or use just one check-in list for all your dates and limit admission through check-in rules. Which
approach is better depends on multiple factors, such as the number of dates in your series. For a
series with one or less event date per day, individual lists are usually more helpful. If you
use dates to represent many time slots on the same day, or even overlapping time slots, working with
just one large check-in list will be easier.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ cl_formset.prefix }}">
{{ cl_formset.management_form }}
{% bootstrap_formset_errors cl_formset %}
@@ -157,6 +157,16 @@
</fieldset>
<fieldset>
<legend>{% trans "Check-in lists" %}</legend>
<p class="help-block">
{% blocktrans trimmed %}
You can choose to either add one or more check-in lists for every date in your series individually,
or use just one check-in list for all your dates and limit admission through check-in rules. Which
approach is better depends on multiple factors, such as the number of dates in your series. For a
series with one or less event date per day, individual lists are usually more helpful. If you
use dates to represent many time slots on the same day, or even overlapping time slots, working with
just one large check-in list will be easier.
{% endblocktrans %}
</p>
<div class="formset" data-formset data-formset-prefix="{{ cl_formset.prefix }}">
{{ cl_formset.management_form }}
{% bootstrap_formset_errors cl_formset %}
@@ -5,7 +5,7 @@
{% block title %}{% trans "Voucher" %}{% endblock %}
{% block inside %}
<h1>{% trans "Create multiple vouchers" %}</h1>
<form action="" method="post" class="form-horizontal" data-asynctask>
<form action="" method="post" class="form-horizontal" data-asynctask mail-preview-url="{% url "control:event.vouchers.bulk.mail_preview" event=request.event.slug organizer=request.event.organizer.slug %}">
{% csrf_token %}
{% bootstrap_form_errors form %}
<fieldset>
@@ -82,8 +82,54 @@
<fieldset>
<legend>{% trans "Send out emails" %}</legend>
{% bootstrap_field form.send layout="control" %}
{% bootstrap_field form.send_subject layout="horizontal" %}
{% bootstrap_field form.send_message layout="horizontal" %}
<div id="send_subject_panel" class="preview-panel form-group" for="send_subject">
{% with field=form.send_subject item="send_subject" %}
<label class="col-md-3 control-label">{{ field.label }}</label>
<div class="col-md-9">
<ul class="nav nav-tabs">
<li role="presentation" class="active">
<a data-toggle="tab" type="edit" href="#{{ item }}_edit"><i class="fa fa-pencil-square-o fa-fw"></i> {% trans "Edit" %}</a>
</li>
<li role="presentation">
<a data-toggle="tab" type="preview" href="#{{ item }}_preview"><i class="fa fa-tv fa-fw"></i> {% trans "Preview" %}</a>
</li>
</ul>
<div class="tab-content">
<div id="{{ item }}_edit" class="tab-pane fade in active">
{% bootstrap_field field show_label=False form_group_class="" %}
</div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
<div lang="all" for="{{ item }}" class="mail-preview"></div>
</div>
</div>
</div>
{% endwith %}
</div>
<div id="send_message_panel" class="preview-panel form-group" for="send_message">
{% with field=form.send_message item="send_message" %}
<label class="col-md-3 control-label">{{ field.label }}</label>
<div class="col-md-9">
<ul class="nav nav-tabs">
<li role="presentation" class="active">
<a data-toggle="tab" type="edit" href="#{{ item }}_edit"><i class="fa fa-pencil-square-o fa-fw"></i> {% trans "Edit" %}</a>
</li>
<li role="presentation">
<a data-toggle="tab" type="preview" href="#{{ item }}_preview"><i class="fa fa-tv fa-fw"></i> {% trans "Preview" %}</a>
</li>
</ul>
<div class="tab-content">
<div id="{{ item }}_edit" class="tab-pane fade in active">
{% bootstrap_field field show_label=False form_group_class="" %}
</div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
<div lang="all" for="{{ item }}" class="mail-preview"></div>
</div>
</div>
</div>
{% endwith %}
</div>
{% bootstrap_field form.send_recipients layout="horizontal" %}
</fieldset>
{% eventsignal request.event "pretix.control.signals.voucher_form_html" form=form %}
+14
View File
@@ -164,7 +164,15 @@ urlpatterns = [
organizer.MembershipDeleteView.as_view(), name='organizer.customer.membership.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/customer/(?P<customer>[^/]+)/anonymize$',
organizer.CustomerAnonymizeView.as_view(), name='organizer.customer.anonymize'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media$', organizer.ReusableMediaListView.as_view(), name='organizer.reusable_media'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/add$',
organizer.ReusableMediumCreateView.as_view(), name='organizer.reusable_medium.create'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/(?P<pk>[^/]+)/$',
organizer.ReusableMediumDetailView.as_view(), name='organizer.reusable_medium'),
re_path(r'^organizer/(?P<organizer>[^/]+)/reusable_media/(?P<pk>[^/]+)/edit$',
organizer.ReusableMediumUpdateView.as_view(), name='organizer.reusable_medium.edit'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards$', organizer.GiftCardListView.as_view(), name='organizer.giftcards'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcards/select2$', typeahead.giftcard_select2, name='organizer.giftcards.select2'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/add$', organizer.GiftCardCreateView.as_view(), name='organizer.giftcard.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/$', organizer.GiftCardDetailView.as_view(), name='organizer.giftcard'),
re_path(r'^organizer/(?P<organizer>[^/]+)/giftcard/(?P<giftcard>[^/]+)/edit$', organizer.GiftCardUpdateView.as_view(),
@@ -177,6 +185,7 @@ urlpatterns = [
re_path(r'^organizer/(?P<organizer>[^/]+)/webhook/(?P<webhook>[^/]+)/logs$', organizer.WebHookLogsView.as_view(),
name='organizer.webhook.logs'),
re_path(r'^organizer/(?P<organizer>[^/]+)/devices$', organizer.DeviceListView.as_view(), name='organizer.devices'),
re_path(r'^organizer/(?P<organizer>[^/]+)/devices/select2$', typeahead.devices_select2, name='organizer.devices.select2'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/add$', organizer.DeviceCreateView.as_view(),
name='organizer.device.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/device/bulk_edit$', organizer.DeviceBulkUpdateView.as_view(),
@@ -190,6 +199,7 @@ urlpatterns = [
re_path(r'^organizer/(?P<organizer>[^/]+)/device/(?P<device>[^/]+)/logs$', organizer.DeviceLogView.as_view(),
name='organizer.device.logs'),
re_path(r'^organizer/(?P<organizer>[^/]+)/gates$', organizer.GateListView.as_view(), name='organizer.gates'),
re_path(r'^organizer/(?P<organizer>[^/]+)/gates/select2$', typeahead.gate_select2, name='organizer.gates.select2'),
re_path(r'^organizer/(?P<organizer>[^/]+)/gate/add$', organizer.GateCreateView.as_view(), name='organizer.gate.add'),
re_path(r'^organizer/(?P<organizer>[^/]+)/gate/(?P<gate>[^/]+)/edit$', organizer.GateUpdateView.as_view(),
name='organizer.gate.edit'),
@@ -211,6 +221,7 @@ urlpatterns = [
name='organizer.export.scheduled.run'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
name='organizer.export.scheduled.delete'),
re_path(r'^organizer/(?P<organizer>[^/]+)/ticket_select2$', typeahead.ticket_select2, name='organizer.ticket_select2'),
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
re_path(r'^events/$', main.EventList.as_view(), name='events'),
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
@@ -220,6 +231,7 @@ urlpatterns = [
re_path(r'^search/payments/$', search.PaymentSearch.as_view(), name='search.payments'),
re_path(r'^event/(?P<organizer>[^/]+)/(?P<event>[^/]+)/', include([
re_path(r'^$', dashboards.event_index, name='event.index'),
re_path(r'^qrcode.(?P<filetype>(png|jpeg|gif|svg))$', event.EventQRCode.as_view(), name='event.qrcode'),
re_path(r'^widgets.json$', dashboards.event_index_widgets_lazy, name='event.index.widgets'),
re_path(r'^logs/embed$', dashboards.event_index_log_lazy, name='event.index.logs'),
re_path(r'^live/$', event.EventLive.as_view(), name='event.live'),
@@ -271,6 +283,7 @@ urlpatterns = [
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/select2$', typeahead.items_select2, name='event.items.select2'),
re_path(r'^items/select2/itemvar$', typeahead.itemvar_select2, name='event.items.itemvar.select2'),
re_path(r'^items/select2/variation$', typeahead.variations_select2, name='event.items.variations.select2'),
re_path(r'^categories/$', item.CategoryList.as_view(), name='event.items.categories'),
re_path(r'^categories/select2$', typeahead.category_select2, name='event.items.categories.select2'),
@@ -321,6 +334,7 @@ urlpatterns = [
re_path(r'^vouchers/add$', vouchers.VoucherCreate.as_view(), name='event.vouchers.add'),
re_path(r'^vouchers/go$', vouchers.VoucherGo.as_view(), name='event.vouchers.go'),
re_path(r'^vouchers/bulk_add$', vouchers.VoucherBulkCreate.as_view(), name='event.vouchers.bulk'),
re_path(r'^vouchers/bulk_add/mail_preview$', vouchers.VoucherBulkMailPreview.as_view(), name='event.vouchers.bulk.mail_preview'),
re_path(r'^vouchers/bulk_action$', vouchers.VoucherBulkAction.as_view(), name='event.vouchers.bulkaction'),
re_path(r'^orders/(?P<code>[0-9A-Z]+)/transition$', orders.OrderTransition.as_view(),
name='event.order.transition'),
+41 -3
View File
@@ -38,10 +38,13 @@ import operator
import re
from collections import OrderedDict
from decimal import Decimal
from io import BytesIO
from itertools import groupby
from urllib.parse import urlsplit
import bleach
import qrcode
import qrcode.image.svg
from django.apps import apps
from django.conf import settings
from django.contrib import messages
@@ -86,7 +89,7 @@ from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views.mailsetup import MailSettingsSetupView
from pretix.control.views.user import RecentAuthenticationRequiredMixin
from pretix.helpers.database import rolledback_transaction
from pretix.multidomain.urlreverse import get_event_domain
from pretix.multidomain.urlreverse import build_absolute_uri, get_event_domain
from pretix.plugins.stripe.payment import StripeSettingsHolder
from pretix.presale.style import regenerate_css
@@ -712,8 +715,11 @@ class MailSettingsPreview(EventPermissionRequiredMixin, View):
ctx = {}
for p in get_available_placeholders(self.request.event, MailSettingsForm.base_context[item]).values():
s = str(p.render_sample(self.request.event))
if s.strip().startswith('*'):
ctx[p.identifier] = s
if s.strip().startswith('* '):
ctx[p.identifier] = '<div class="placeholder" title="{}">{}</div>'.format(
_('This value will be replaced based on dynamic parameters.'),
markdown_compile_email(s)
)
else:
ctx[p.identifier] = '<span class="placeholder" title="{}">{}</span>'.format(
_('This value will be replaced based on dynamic parameters.'),
@@ -1501,3 +1507,35 @@ class QuickSetupView(FormView):
},
] if self.request.method != "POST" else []
)
class EventQRCode(EventPermissionRequiredMixin, View):
permission = 'can_change_event_settings'
def get(self, request, *args, filetype, **kwargs):
url = build_absolute_uri(request.event, 'presale:event.index')
qr = qrcode.QRCode(
version=1,
error_correction=qrcode.constants.ERROR_CORRECT_M,
box_size=10,
border=4,
)
qr.add_data(url)
qr.make(fit=True)
if filetype == 'svg':
factory = qrcode.image.svg.SvgPathImage
img = qrcode.make('Some data here', image_factory=factory)
r = HttpResponse(img.to_string(), content_type='image/svg+xml')
r['Content-Disposition'] = f'inline; filename="qrcode-{request.event.slug}.{filetype}"'
return r
elif filetype in ('jpeg', 'png', 'gif'):
img = qr.make_image(fill_color="black", back_color="white")
byte_io = BytesIO()
img.save(byte_io, filetype.upper())
byte_io.seek(0)
r = HttpResponse(byte_io.read(), content_type='image/' + filetype)
r['Content-Disposition'] = f'inline; filename="qrcode-{request.event.slug}.{filetype}"'
return r
+32 -2
View File
@@ -136,6 +136,11 @@ def item_move(request, item, up=True):
if item.position != i:
item.position = i
item.save()
item.log_action(
'pretix.event.item.reordered', user=request.user, data={
'position': i,
}
)
messages.success(request, _('The order of items has been updated.'))
@@ -185,6 +190,11 @@ def reorder_items(request, organizer, event):
if pos != i.position: # Save unneccessary UPDATE queries
i.position = pos
i.save(update_fields=['position'])
i.log_action(
'pretix.event.item.reordered', user=request.user, data={
'position': i,
}
)
return HttpResponse()
@@ -243,7 +253,7 @@ class CategoryUpdate(EventPermissionRequiredMixin, UpdateView):
messages.success(self.request, _('Your changes have been saved.'))
if form.has_changed():
self.object.log_action(
'pretix.event.category.changed', user=self.request.user, data={
'pretix.event.category.reordered', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
)
@@ -338,6 +348,11 @@ def category_move(request, category, up=True):
if cat.position != i:
cat.position = i
cat.save()
cat.log_action(
'pretix.event.category.reordered', user=request.user, data={
'position': i,
}
)
messages.success(request, _('The order of categories has been updated.'))
@@ -381,6 +396,11 @@ def reorder_categories(request, organizer, event):
if pos != c.position: # Save unneccessary UPDATE queries
c.position = pos
c.save(update_fields=['position'])
c.log_action(
'pretix.event.category.reordered', user=request.user, data={
'position': pos,
}
)
return HttpResponse()
@@ -510,6 +530,11 @@ def reorder_questions(request, organizer, event):
if pos != q.position: # Save unneccessary UPDATE queries
q.position = pos
q.save(update_fields=['position'])
q.log_action(
'pretix.event.question.reordered', user=request.user, data={
'position': pos,
}
)
system_question_order = {}
for s in ('attendee_name_parts', 'attendee_email', 'company', 'street', 'zipcode', 'city', 'country'):
@@ -518,6 +543,11 @@ def reorder_questions(request, organizer, event):
else:
system_question_order[s] = -1
request.event.settings.system_question_order = system_question_order
request.event.log_action(
'pretix.event.settings', user=request.user, data={
'system_question_order': system_question_order,
}
)
return HttpResponse()
@@ -731,7 +761,7 @@ class QuestionUpdate(EventPermissionRequiredMixin, QuestionMixin, UpdateView):
if form.has_changed():
self.object.log_action(
'pretix.event.question.changed', user=self.request.user, data={
'pretix.event.question.reordered', user=self.request.user, data={
k: form.cleaned_data.get(k) for k in form.changed_data
}
)
+5 -4
View File
@@ -306,10 +306,11 @@ class EventWizard(SafeSessionWizardView):
event.set_active_plugins(settings.PRETIX_PLUGINS_DEFAULT.split(","),
allow_restricted=settings.PRETIX_PLUGINS_DEFAULT.split(","))
event.save(update_fields=['plugins'])
event.checkin_lists.create(
name=_('Default'),
all_products=True
)
if not event.has_subevents:
event.checkin_lists.create(
name=_('Default'),
all_products=True
)
event.set_defaults()
if basics_data['tax_rate']:
+2 -2
View File
@@ -367,7 +367,7 @@ class OrderDetail(OrderView):
'item', 'variation', 'addon_to', 'tax_rule', 'used_membership', 'used_membership__membership_type',
'discount',
).prefetch_related(
'item__questions', 'issued_gift_cards',
'item__questions', 'issued_gift_cards', 'linked_media',
Prefetch('answers', queryset=QuestionAnswer.objects.prefetch_related('options').select_related('question')),
Prefetch('all_checkins', queryset=Checkin.all.select_related('list').order_by('datetime')),
).order_by('positionid')
@@ -2276,7 +2276,7 @@ class ExportMixin:
continue
if self.scheduled:
initial = self.scheduled.export_form_data
initial = dict(self.scheduled.export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
+105 -6
View File
@@ -73,7 +73,7 @@ from pretix.base.i18n import language
from pretix.base.models import (
CachedFile, Customer, Device, Gate, GiftCard, Invoice, LogEntry,
Membership, MembershipType, Order, OrderPayment, OrderPosition, Organizer,
ScheduledOrganizerExport, Team, TeamInvite, User,
ReusableMedium, ScheduledOrganizerExport, Team, TeamInvite, User,
)
from pretix.base.models.customers import CustomerSSOClient, CustomerSSOProvider
from pretix.base.models.event import Event, EventMetaProperty, EventMetaValue
@@ -92,7 +92,7 @@ from pretix.base.views.tasks import AsyncAction
from pretix.control.forms.exports import ScheduledOrganizerExportForm
from pretix.control.forms.filter import (
CustomerFilterForm, DeviceFilterForm, EventFilterForm, GiftCardFilterForm,
OrganizerFilterForm, TeamFilterForm,
OrganizerFilterForm, ReusableMediaFilterForm, TeamFilterForm,
)
from pretix.control.forms.orders import ExporterForm
from pretix.control.forms.organizer import (
@@ -100,8 +100,9 @@ from pretix.control.forms.organizer import (
EventMetaPropertyForm, GateForm, GiftCardCreateForm, GiftCardUpdateForm,
MailSettingsForm, MembershipTypeForm, MembershipUpdateForm,
OrganizerDeleteForm, OrganizerFooterLinkFormset, OrganizerForm,
OrganizerSettingsForm, OrganizerUpdateForm, SSOClientForm, SSOProviderForm,
TeamForm, WebHookForm,
OrganizerSettingsForm, OrganizerUpdateForm, ReusableMediumCreateForm,
ReusableMediumUpdateForm, SSOClientForm, SSOProviderForm, TeamForm,
WebHookForm,
)
from pretix.control.forms.rrule import RRuleForm
from pretix.control.logdisplay import OVERVIEW_BANLIST
@@ -533,7 +534,7 @@ class OrganizerCreate(CreateView):
organizer=form.instance, name=_('Administrators'),
all_events=True, can_create_events=True, can_change_teams=True, can_manage_gift_cards=True,
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
can_manage_customers=True,
can_manage_customers=True, can_manage_reusable_media=True,
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
)
t.members.add(self.request.user)
@@ -1550,7 +1551,7 @@ class ExportMixin:
if id != ex.identifier:
continue
if self.scheduled:
initial = self.scheduled.export_form_data
initial = dict(self.scheduled.export_form_data)
test_form = ExporterForm(data=self.request.GET, prefix=ex.identifier)
test_form.fields = ex.export_form_fields
@@ -2738,3 +2739,101 @@ class CustomerAnonymizeView(OrganizerDetailViewMixin, OrganizerPermissionRequire
'organizer': self.request.organizer.slug,
'customer': self.object.identifier,
})
class ReusableMediaListView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, PaginationMixin, ListView):
model = ReusableMedium
template_name = 'pretixcontrol/organizers/reusable_media.html'
permission = 'can_manage_reusable_media'
context_object_name = 'media'
def get_queryset(self):
qs = self.request.organizer.reusable_media.select_related(
'customer', 'linked_orderposition', 'linked_orderposition__order', 'linked_orderposition__order__event',
'linked_giftcard'
)
if self.filter_form.is_valid():
qs = self.filter_form.filter_qs(qs)
return qs
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['filter_form'] = self.filter_form
return ctx
@cached_property
def filter_form(self):
return ReusableMediaFilterForm(data=self.request.GET, request=self.request)
class ReusableMediumDetailView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, TemplateView):
template_name = 'pretixcontrol/organizers/reusable_medium.html'
permission = 'can_manage_reusable_media'
@cached_property
def medium(self):
return get_object_or_404(
self.request.organizer.reusable_media,
pk=self.kwargs.get('pk')
)
def get_context_data(self, **kwargs):
ctx = super().get_context_data(**kwargs)
ctx['medium'] = self.medium
return ctx
class ReusableMediumCreateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, CreateView):
template_name = 'pretixcontrol/organizers/reusable_medium_edit.html'
permission = 'can_manage_reusable_media'
context_object_name = 'medium'
form_class = ReusableMediumCreateForm
def get_form_kwargs(self):
ctx = super().get_form_kwargs()
c = ReusableMedium(organizer=self.request.organizer)
ctx['instance'] = c
return ctx
def form_valid(self, form):
r = super().form_valid(form)
form.instance.log_action('pretix.reusable_medium.created', user=self.request.user, data={
k: getattr(form.instance, k)
for k in form.changed_data
})
messages.success(self.request, _('Your changes have been saved.'))
return r
def get_success_url(self):
return reverse('control:organizer.reusable_medium', kwargs={
'organizer': self.request.organizer.slug,
'pk': self.object.pk,
})
class ReusableMediumUpdateView(OrganizerDetailViewMixin, OrganizerPermissionRequiredMixin, UpdateView):
template_name = 'pretixcontrol/organizers/reusable_medium_edit.html'
permission = 'can_manage_reusable_media'
context_object_name = 'medium'
form_class = ReusableMediumUpdateForm
def get_object(self, queryset=None):
return get_object_or_404(
self.request.organizer.reusable_media,
pk=self.kwargs.get('pk')
)
def form_valid(self, form):
if form.has_changed():
self.object.log_action('pretix.reusable_medium.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 get_success_url(self):
return reverse('control:organizer.reusable_medium', kwargs={
'organizer': self.request.organizer.slug,
'pk': self.object.pk,
})

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