Compare commits

...
Author SHA1 Message Date
Raphael Michel bb82d013e6 OrderChangeManager: Prevent usage on canceled orders 2023-01-24 18:54:46 +01:00
Raphael Michel 3bc8450d4f Email shredder: Also shred attendee emails and incoming bounces 2023-01-24 18:18:29 +01:00
Raphael MichelandGitHub fdcad926f9 Changing orders: Default to not notifying the user (#3056) 2023-01-24 16:16:29 +01:00
Raphael Michel 433262f6fc Prepare for DeleteView change in Django 4.0 2023-01-24 14:16:01 +01:00
FabianandGitHub 50596b7543 bump debian version (#3055) 2023-01-23 21:37:17 +01:00
Raphael Michel 988188b00a Scheduled exports: Fix missing event context, fix form initial 2023-01-23 11:31:54 +01:00
Raphael MichelandGitHub fdc15a753c Scheduled exports: Set owner to cc instead of to if there is an explicit recipient (#3045) 2023-01-23 11:10:47 +01:00
Raphael Michel 785cc49a2e Bank transfer: Fix SEPA debits not shown on organizer level 2023-01-20 16:59:25 +01:00
Raphael MichelandGitHub 863fd3065a Optimize CheckinList.inside_count (#3043) 2023-01-20 16:02:19 +01:00
Raphael Michel ac361a8f47 Scheduled exports: Use proper JSON encoder 2023-01-20 12:59:38 +01:00
Raphael MichelandGitHub 56d928d5ec Widget: Do not declare products "FREE" if they have mandatory addons (#3041) 2023-01-20 09:15:14 +01:00
Richard SchreiberandGitHub 6c3e745d5d Control: Remove empty help-text for colorpickers with no-contrast (#3042) 2023-01-20 08:50:08 +01:00
Raphael Michel b29efb9694 Scheduled exports: Add required transaction 2023-01-19 18:41:46 +01:00
Raphael Michel 5ee1213dbf Gift card list export: Use date picker 2023-01-19 17:46:06 +01:00
Raphael Michel c29dc49819 Scheduled exports: Lock exports while setting their new time 2023-01-19 16:31:47 +01:00
Raphael Michel 8b74f791f4 Export schedule: Fix computation of start time on same day 2023-01-19 14:34:27 +01:00
Raphael MichelandRaphael Michel 4d75438a11 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5081 of 5081 strings)

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

powered by weblate
2023-01-19 13:37:58 +01:00
Raphael MichelandRaphael Michel 781002b27e Translations: Update German
Currently translated at 100.0% (5081 of 5081 strings)

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

powered by weblate
2023-01-19 13:37:58 +01:00
Raphael Michel f7c0e8c8d0 Translations: Extend German word list 2023-01-19 13:20:14 +01:00
Raphael Michel 70a3516725 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-19 11:47:34 +01:00
Raphael Michel 3133e18b22 Fix isort issue 2023-01-19 11:46:56 +01:00
Raphael Michel 3257c59117 Delete checkins when deleting orders 2023-01-19 11:46:51 +01:00
Raphael MichelandGitHub 19d1a8de71 Scheduled exports (#3033) 2023-01-19 11:46:30 +01:00
Richard SchreiberandGitHub 0bb5af191b Product list: Fix add-to-cart-button being shown on seating-only event (#3038) 2023-01-19 10:56:48 +01:00
Raphael Michel 8fe56b7278 Export: Fix date range validation 2023-01-19 10:51:18 +01:00
Martin Gross df432b1958 Presale: Set "Contact Event Organizer"-mailto href to _blank 2023-01-18 17:29:18 +01:00
Raphael Michel 54434f07a9 Email settings: Order languages of preview like form 2023-01-18 12:23:43 +01:00
Raphael Michel 0ecbee48ae Stripe: Catch failing promise on JS level 2023-01-18 11:47:28 +01:00
Maurice KaagandRaphael Michel ff2fa43ba1 Translations: Update French
Currently translated at 63.3% (128 of 202 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Maurice KaagandRaphael Michel 3a1cefbbe7 Translations: Update French
Currently translated at 48.9% (2464 of 5029 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Raphael MichelandRaphael Michel 7aa433e9af Translations: Update French
Currently translated at 46.8% (2354 of 5029 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Maurice KaagandRaphael Michel c5a5d13158 Translations: Update French
Currently translated at 62.8% (127 of 202 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Maurice KaagandRaphael Michel 2e256e30be Translations: Update French
Currently translated at 46.8% (2354 of 5029 strings)

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

powered by weblate
2023-01-18 11:39:23 +01:00
Raphael Michel 0fbc0c3ffb Refresh order status after applying gift card 2023-01-17 15:26:31 +01:00
Richard SchreiberandGitHub 93950d3fac Presale: separate multiple lines by comma in ical event location (#3037) 2023-01-17 13:17:58 +01:00
e8269ed1bf Update translations (#3031)
Co-authored-by: Raphael Michel <michel@rami.io>
2023-01-13 15:43:06 +01:00
Raphael Michel 3fa1fbf6e2 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-13 15:07:59 +01:00
Raphael Michel 8114b47c8c API: Support for date ranges in exports 2023-01-13 13:48:45 +01:00
Raphael Michel dcf5e67196 Fix minor issues in DateFrameField/DateFrameWidget 2023-01-13 13:30:12 +01:00
bf4569b080 Exports: Add predefined timeframes (#3027)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-13 13:14:08 +01:00
Aurélia BOUYGEandRaphael Michel 95979143d7 Translations: Update French
Currently translated at 45.7% (2284 of 4995 strings)

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

powered by weblate
2023-01-13 11:20:00 +01:00
treeandRaphael Michel 4c5e77c2ef Translations: Update Czech
Currently translated at 85.1% (172 of 202 strings)

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

powered by weblate
2023-01-13 11:20:00 +01:00
treeandRaphael Michel 95b4f08aeb Translations: Update Czech
Currently translated at 26.7% (1336 of 4995 strings)

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

powered by weblate
2023-01-13 11:20:00 +01:00
Raphael Michel d6605e668b Bump Pillow to 9.4.* 2023-01-13 11:19:21 +01:00
Raphael Michel 6ee348548f Bump django-compressor to 4.3.* 2023-01-13 11:18:39 +01:00
Raphael Michel fca8e48f6a Bump arabic-reshaper to 3.0.0 2023-01-13 11:18:20 +01:00
Raphael Michel 5a295934f7 Update select2 from 4.0.6-rc.1 to 4.0.13 2023-01-13 10:49:54 +01:00
Raphael Michel 4385b41e8b Item typeahead: Allow search by internal name 2023-01-13 10:40:39 +01:00
Raphael Michel 92dacfb966 Only run new validation on newly uploaded files 2023-01-12 19:00:40 +01:00
Raphael Michel d1acbad181 Export: Fix issue showing error messages 2023-01-12 18:06:23 +01:00
Richard SchreiberandGitHub d0676765a4 Checkout: remove Indonesia from zipcode-validation (#3029) 2023-01-12 16:57:13 +01:00
Raphael MichelandGitHub 9dd3b12625 Validate image size in pixels at upload time (#3003) 2023-01-12 16:30:28 +01:00
Raphael Michel 738301d2af CI: Fix syntax error 2023-01-12 15:13:58 +01:00
Raphael Michel f7f29e8a55 Do not read language from session any more (deprecated since Django 3.0) 2023-01-12 15:00:37 +01:00
Raphael MichelandGitHub ad69ec293f CI: Use own codecov upload token to prevent rate limit issue (#3028) 2023-01-12 13:36:13 +01:00
Raphael MichelandGitHub 3443296a28 Device list: Hide revoked devices by default (#2996) 2023-01-12 13:35:43 +01:00
Richard Schreiber 7a69e00d39 Control: improve settings-icon for non-personalized tickets 2023-01-12 11:00:43 +01:00
Raphael Michel bddc91d595 Export: Fix handling of form validation errors 2023-01-12 09:56:45 +01:00
Raphael Michel 0c0d8b2c55 ItemDataExporter: Fix off-by-one 2023-01-12 09:45:17 +01:00
Raphael Michel c018921a18 Translate label of JSONExporter 2023-01-11 15:10:14 +01:00
Raphael MichelandRaphael Michel f33aa3fdba Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael MichelandRaphael Michel 7b55f85663 Translations: Update German
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael MichelandRaphael Michel fb9909ca83 Translations: Update German
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael MichelandRaphael Michel 35e8bab7a5 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael MichelandRaphael Michel bf34e73121 Translations: Update German
Currently translated at 99.9% (4994 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael MichelandRaphael Michel 39e2715f3c Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4995 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael MichelandRaphael Michel 97d2b015cf Translations: Update German
Currently translated at 99.9% (4994 of 4995 strings)

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

powered by weblate
2023-01-11 13:54:57 +01:00
Raphael Michel ca30a07da3 Update translation wordlists 2023-01-11 13:48:22 +01:00
Raphael Michel 81d31ce64c Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-11 13:07:47 +01:00
0ae66ab7f6 Reorganize UI for exporters (#3025)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-11 12:34:56 +01:00
Raphael Michel cb4af51c01 Sendmail: Fix issue loading old logs 2023-01-10 17:36:53 +01:00
Raphael Michel 6b44cae607 Fix incorrect handling of admission/personalized in API PATCH 2023-01-10 17:28:58 +01:00
Raphael Michel 1a4d4029c9 Sendmail: Fix incorrect placeholder promoted for waiting list 2023-01-10 13:28:22 +01:00
Raphael Michel 3563653d55 Payment step: Fix edge case when redeeming gift cards with service fees 2023-01-10 13:17:27 +01:00
Raphael MichelandRaphael Michel e4c9afa87a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4961 of 4961 strings)

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

powered by weblate
2023-01-10 12:34:37 +01:00
Raphael MichelandRaphael Michel 6938397a6a Translations: Update German
Currently translated at 100.0% (4961 of 4961 strings)

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

powered by weblate
2023-01-10 12:34:37 +01:00
Raphael Michel 24e5b593ea Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-10 12:04:50 +01:00
Raphael Michel cd237d4c19 Sendmail: Improve wording 2023-01-10 12:04:03 +01:00
9b1d7cc522 Sendmail: Abstract away to allow more types of recipients (#2994)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-10 12:03:50 +01:00
Raphael Michel d07948613a Validate tax rates to be between 0 and 100 2023-01-10 11:48:42 +01:00
Raphael MichelandRaphael Michel eadc1b4812 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael MichelandRaphael Michel 787d4ec06b Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Richard SchreiberandRaphael Michel ca1d13421f Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Richard SchreiberandRaphael Michel 495ae25b9e Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael MichelandRaphael Michel d98accdd2d Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael MichelandRaphael Michel 746ced9e93 Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael MichelandRaphael Michel d72bbffc51 Translations: Update German
Currently translated at 100.0% (4953 of 4953 strings)

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

powered by weblate
2023-01-09 16:01:58 +01:00
Raphael Michel 8503623472 Add word to German wordlist 2023-01-09 15:32:36 +01:00
Raphael Michel 4f097e279a Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-09 14:58:25 +01:00
603225d042 Separate personalization from admission (#2990)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-09 14:57:35 +01:00
e5528f7784 Writable API for ticket layouts (#3004)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-09 13:44:01 +01:00
Raphael Michel 2e702b87de Do not show empty invoice address fields on confirmation 2023-01-09 11:24:30 +01:00
Raphael Michel 59730ff501 Stripe: Rename SOFORT's public name 2023-01-09 10:03:37 +01:00
Raphael Michel 280c24528f API: Fix crash when creating item variations with require_membership_types 2023-01-09 10:03:19 +01:00
Raphael Michel ff09ed422c Prevent requiring a membership without selecting any types 2023-01-06 23:17:00 +01:00
Raphael Michel b3be64b9f3 Bank transfer: Small parser improvement 2023-01-05 09:41:35 +01:00
Raphael Michel 018c3d70e3 API: Allow to set order of check-in lists 2023-01-04 18:29:35 +01:00
Raphael Michel a2f2d25169 Allow users with can_checkin_orders permission to use the bulk actions of the check-in list view 2023-01-04 18:13:24 +01:00
Raphael Michel 4747a4c480 Order change view: Remove a few buttons for read-only users 2023-01-04 18:13:24 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
ed9a9246e3 Bump @babel/core from 7.20.5 to 7.20.7 in /src/pretix/static/npm_dir (#3002)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2023-01-02 11:42:41 +01:00
Raphael Michel 6e63d34932 Cart: Prevent TypeError mixing seated and unseated lines 2023-01-02 10:33:51 +01:00
Raphael Michel db06ed132a PPv2: Fix invalid cart payments in edge case (PRETIXEU-7QG) 2023-01-02 10:19:31 +01:00
Raphael Michel ddbe38ca53 API: Do not crash if invalid data type is given for name_parts 2023-01-02 10:17:09 +01:00
Raphael Michel d3698b3e2f Widget: Annotate parts of widget source code 2022-12-22 11:36:22 +01:00
Fazenda DengoandRaphael Michel ff828ecc92 Translations: Update Portuguese (Portugal)
Currently translated at 85.6% (4228 of 4934 strings)

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

powered by weblate
2022-12-22 10:47:53 +01:00
Fazenda DengoandRaphael Michel d0236572f0 Translations: Update Portuguese (Brazil)
Currently translated at 12.9% (637 of 4934 strings)

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

powered by weblate
2022-12-22 10:47:53 +01:00
Raphael Michel 8ea6f3bc7d Fix #2499 -- Incorrect type detection order in RelativeDateWrapper 2022-12-21 15:34:31 +01:00
Raphael Michel 5587aebcd8 Fix failing widget tests 2022-12-21 15:02:36 +01:00
Raphael Michel 0b708067de Widget: Support for seated waiting list 2022-12-21 12:17:32 +01:00
Raphael Michel e75dc74661 Allow consecutive password resets 2022-12-21 10:01:25 +01:00
f0c5e54e34 PPv2: Fix continue button behaviour (#2821)
Co-authored-by: Richard Schreiber <wiffbi@gmail.com>
2022-12-20 16:09:42 +01:00
Raphael Michel eeb6e11934 PPv2: Revert format change of log entries 2022-12-20 16:01:44 +01:00
Raphael Michel 1238165e7a PPv2: Handle types correctly in webhook 2022-12-20 15:29:37 +01:00
Raphael Michel bcf65603e4 Enable database health checks for when we use Django 4.1
4.1 is a while in the future but I really don't want to forget this then
as it is so useful!
2022-12-20 14:54:31 +01:00
Raphael Michel c4aed04a18 PPv2: Fix incomplete validation of capture status 2022-12-20 14:54:31 +01:00
Raphael Michel 8be09ee937 Bump dnspython to 2.2.* 2022-12-20 14:54:31 +01:00
Richard SchreiberandGitHub a1ec45daf6 Fix addon typo (#2987) 2022-12-20 09:15:38 +01:00
Raphael Michel 9b0b8e2061 GitHub Actions: Set FORCE_COLOR = 1 2022-12-19 15:31:08 +01:00
Raphael Michel b6e65e7356 PPv2: Fix CSP issue in 3D secure verification 2022-12-19 14:53:46 +01:00
Raphael Michel 5d82305e18 CSP: Deduplicate identical values 2022-12-19 14:53:32 +01:00
Raphael Michel c8983ca863 CSP: Do not set nonce if unsafe-inline is set 2022-12-19 14:52:58 +01:00
Raphael Michel 52f6b7c971 GitHub: Try to use a better dependabot strategy 2022-12-19 14:03:48 +01:00
Raphael MichelandGitHub 809177397a Update celery to 5.2 (#2983) 2022-12-19 13:56:16 +01:00
Raphael Michel b83cb7d8c4 Bump PyPDF2 to 2.12.* 2022-12-19 13:55:20 +01:00
Raphael Michel bfd980fc30 Bump isort to 5.11.* 2022-12-19 13:54:18 +01:00
Raphael Michel 5bc3503d04 Bump django-debug-toolbar to 3.8.* 2022-12-19 13:53:54 +01:00
Raphael Michel a582db3280 Set stacklevel=2 on DeprecationWarning 2022-12-19 13:53:44 +01:00
Raphael Michel bd4ea5d8f8 Reduce number of rows in invoice preview 2022-12-19 13:00:53 +01:00
Raphael Michel 5dec94606b Do not require new plugins to sett default=True on their AppConfig 2022-12-19 12:34:49 +01:00
Raphael Michel ab97082c85 Remove all RemovedInDjango40Warning exceptions 2022-12-19 12:30:48 +01:00
Raphael Michel 0723ff92ee Stricter deprecation warnings 2022-12-19 12:30:24 +01:00
Raphael MichelandGitHub 15272cc3e6 Bump django-oauth-toolkit to 2.2.* (#2985) 2022-12-19 12:26:45 +01:00
Raphael Michel 60554dad9a Remove usage of deprecated Django APIs 2022-12-17 16:26:24 +01:00
Raphael MichelandGitHub b288ea1e96 Docs: Add procedure after debian updates (#2984) 2022-12-16 19:28:24 +01:00
Raphael Michel 6a4b792501 API: Fix using invoice address attributes in "include" 2022-12-16 15:23:35 +01:00
8dd83e5a35 Add lifetime spending to customer details (#2934)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-12-16 15:20:22 +01:00
Raphael Michel bd5c9a4cb5 Order search: Further query-specific fine tuning 2022-12-15 16:27:42 +01:00
Raphael Michel 0cd8bbf9a9 Order search: Fix missing field in only() call 2022-12-15 16:27:42 +01:00
Raphael Michel d46989473b Customer accounts: Show event date in order list 2022-12-15 16:27:42 +01:00
Richard SchreiberandGitHub b31b2d34c0 Editor: fix sample text when key missing (#2980) 2022-12-15 16:20:11 +01:00
Richard SchreiberandGitHub 5e963d87d9 Presale: Improve visibiltity of edit links on order confirm/details page (Z#23108817) 2022-12-15 11:22:28 +01:00
Raphael Michel a8e0eea69a Docs: Fix very old API compatibility statement 2022-12-15 10:35:23 +01:00
Raphael Michel efa9f6dfe5 Order search: Fix missing field in only() call 2022-12-14 18:19:44 +01:00
Raphael Michel 857377d16c Work around performance issue in get_all_payment_providers 2022-12-14 18:14:01 +01:00
Raphael MichelandRaphael Michel 229b6fed4a Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (4934 of 4934 strings)

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

powered by weblate
2022-12-14 16:44:53 +01:00
Raphael MichelandRaphael Michel b2e4fb6db3 Translations: Update German
Currently translated at 100.0% (4934 of 4934 strings)

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

powered by weblate
2022-12-14 16:44:53 +01:00
Raphael Michel 16b15057fd Translations: Add XXX, XXXX, XXXXX to wordlist 2022-12-14 16:33:36 +01:00
Raphael Michel e4168ff06a Translations: Add XXX, XXXX, XXXXX to wordlist 2022-12-14 16:29:48 +01:00
Raphael Michel b208db32c7 Fix wrong version number 2022-12-14 14:13:40 +01:00
Raphael Michel ce177227c7 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2022-12-14 14:09:36 +01:00
Raphael Michel 2cd70ef434 Bump redis to 4.4.* 2022-12-14 14:08:52 +01:00
Raphael Michel 633755ab13 Bump django-countries to 7.5.* 2022-12-14 14:08:52 +01:00
Raphael Michel 6ade32d7cb Bump pycryptodome to 3.16.* 2022-12-14 14:08:52 +01:00
Raphael MichelandGitHub cea6c340be Bank transfer: Allow to send the invoice direclty to the accounting department (#2975) 2022-12-14 14:08:50 +01:00
Raphael Michel ad1dab3b7f Bank transfer: Fix refund export when plugin is disabled 2022-12-13 18:40:26 +01:00
Raphael Michel 930abe0cc5 Fix crash in gift card view (PRETIXEU-493) 2022-12-13 18:32:22 +01:00
Raphael Michel ba2cc56c82 Radio collapse elements: Deal with Firefox keeping form state on reload 2022-12-13 10:54:18 +01:00
Raphael Michel cb1f63bf80 Fix regression in address validation for resellers 2022-12-12 17:21:55 +01:00
aab7042cda PPv2: Simulate cart_payments in XHR-calls; only look at multi_use-payments for remaining value calculation (#2970)
Co-authored-by: Raphael Michel <michel@rami.io>
2022-12-12 15:35:16 +01:00
Raphael MichelandGitHub 495a21c683 GitHub actions: Ignore flake8 no longer supporting Python 3.7 (#2971) 2022-12-12 15:29:47 +01:00
Martin Gross 86b5ba6937 PPv2: Actually log dict-representation on value mismatch 2022-12-12 12:44:07 +01:00
Raphael MichelandGitHub 3d9679a144 Allow variations to override item meta data (#2965) 2022-12-12 12:06:09 +01:00
Raphael Michel 5f899ed5c5 Bump chardet to 5.1.* 2022-12-12 12:03:27 +01:00
Raphael Michel 47dabc1fe7 Bump pytest-xdist to 3.1.* 2022-12-12 10:53:32 +01:00
Raphael MichelandRaphael Michel 2d7c4a3d42 Translations: Add Croatian 2022-12-12 10:53:09 +01:00
Raphael MichelandRaphael Michel 51ef98f736 Translations: Add Croatian 2022-12-12 10:53:09 +01:00
Mie FrydensbjergandRaphael Michel 2d7d2b1a90 Translations: Update Danish
Currently translated at 71.1% (143 of 201 strings)

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

powered by weblate
2022-12-12 10:53:09 +01:00
Mie FrydensbjergandRaphael Michel cede7ba3aa Translations: Update Danish
Currently translated at 35.6% (1755 of 4919 strings)

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

powered by weblate
2022-12-12 10:53:09 +01:00
Raphael Michel 4fd8726b05 Bump flake8 to 6.0.*, pycodestyle to 2.10.* and pyflakes to 3.0.* 2022-12-12 10:53:01 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
b344ce90ba Bump vue and vue-template-compiler in /src/pretix/static/npm_dir (#2940)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-12 09:25:36 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
69dc7f56e5 Bump @babel/core from 7.19.6 to 7.20.5 in /src/pretix/static/npm_dir (#2941)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-12 09:25:23 +01:00
Raphael Michel 247a61489f XLSX generation: Remove invalid unicode characters 2022-12-09 17:41:33 +01:00
Raphael Michel 979d23e997 Invoice renderer: Unify HTML cleaning and clean intro and additional
text
2022-12-09 17:30:26 +01:00
Raphael Michel 28e529995d Add missing license headers 2022-12-09 13:24:17 +01:00
Raphael Michel a982cbf6b6 Name field: Improve compatibility with old formats 2022-12-09 10:42:26 +01:00
Raphael Michel f1c2ae5b6b Revert "Bump pycodestyle to 2.10.*"
This reverts commit dfe3454915.
2022-12-08 14:17:22 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
5b27ac66f9 Bump decode-uri-component from 0.2.0 to 0.2.2 in /src/pretix/static/npm_dir (#2952)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 13:50:47 +01:00
Raphael Michel c71ac2141f Bump drf_ujson2 to 1.7.* 2022-12-08 13:50:20 +01:00
Raphael Michel e59498d65d Bump pytest-rerunfailures to 10.* 2022-12-08 13:50:20 +01:00
Raphael Michel dfe3454915 Bump pycodestyle to 2.10.* 2022-12-08 13:50:20 +01:00
Raphael MichelandGitHub b64c5735a8 Make str.format_map with untrusted input safer (#2931) 2022-12-08 13:49:07 +01:00
dependabot[bot]GitHubdependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
11eecd739d Bump @rollup/plugin-babel from 6.0.2 to 6.0.3 in /src/pretix/static/npm_dir (#2942)
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2022-12-08 13:47:34 +01:00
Raphael Michel 07a6d4898a Fix missing Discount.is_available_by_time method 2022-12-08 10:53:00 +01:00
Raphael Michel a759e23504 Docs: Add internal_name to digital content api 2022-12-08 10:48:13 +01:00
Richard SchreiberandGitHub 3eaf05502a Checkout: copy answers from previous item instead of first (#Z23112272) 2022-12-07 09:24:24 +01:00
04df1c2032 Introduce country-specific address validation (#2945)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2022-12-05 12:42:46 +01:00
Raphael MichelandGitHub 6a8df75a9f Fix regression in handling gift card payments (#2936) 2022-12-05 11:32:27 +01:00
Richard SchreiberandGitHub 547cfdffd6 PDF editor: Reduce precision size of empty page (Z#23112472) (#2935) 2022-12-01 13:19:21 +01:00
Raphael Michel f72a0b4c09 Bump version to 4.16.0.dev0 2022-11-30 09:53:57 +01:00
313 changed files with 175768 additions and 103867 deletions
+1
View File
@@ -9,6 +9,7 @@ updates:
directory: "/src"
schedule:
interval: "daily"
versioning-strategy: increase
- package-ecosystem: "npm"
directory: "/src/pretix/static/npm_dir"
schedule:
+5 -2
View File
@@ -17,16 +17,19 @@ on:
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
spelling:
name: Spellcheck
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
+7 -4
View File
@@ -15,16 +15,19 @@ on:
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
compile:
runs-on: ubuntu-22.04
name: Check gettext syntax
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -47,10 +50,10 @@ jobs:
name: Spellcheck
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
+9 -6
View File
@@ -15,16 +15,19 @@ on:
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
isort:
name: isort
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -42,10 +45,10 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- uses: actions/cache@v1
with:
path: ~/.cache/pip
@@ -63,10 +66,10 @@ jobs:
runs-on: ubuntu-22.04
steps:
- uses: actions/checkout@v2
- name: Set up Python 3.8
- name: Set up Python 3.9
uses: actions/setup-python@v1
with:
python-version: 3.8
python-version: 3.9
- name: Install Dependencies
run: pip3 install licenseheaders
- name: Run licenseheaders
+5 -1
View File
@@ -15,6 +15,9 @@ on:
permissions:
contents: read # to fetch code (actions/checkout)
env:
FORCE_COLOR: 1
jobs:
test:
runs-on: ubuntu-22.04
@@ -60,7 +63,7 @@ jobs:
- name: Install system dependencies
run: sudo apt update && sudo apt install gettext mariadb-client
- name: Install Python dependencies
run: pip3 install -e ".[dev]" mysqlclient psycopg2-binary
run: pip3 install --ignore-requires-python -e ".[dev]" mysqlclient psycopg2-binary # We ignore that flake8 needs newer python as we don't run flake8 during tests
working-directory: ./src
- name: Run checks
run: python manage.py check
@@ -78,5 +81,6 @@ jobs:
uses: codecov/codecov-action@v1
with:
file: src/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true
if: matrix.database == 'postgres' && matrix.python-version == '3.10'
+22 -1
View File
@@ -12,7 +12,7 @@ solution with many things readily set-up, look at :ref:`dockersmallscale`.
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
offers at `pretix.eu`_.
We tested this guide on the Linux distribution **Debian 10.0** but it should work very similar on other
We tested this guide on the Linux distribution **Debian 11.6** but it should work very similar on other
modern distributions, especially on all systemd-based ones.
Requirements
@@ -318,6 +318,27 @@ example::
(venv)$ python -m pretix rebuild
# systemctl restart pretix-web pretix-worker
System updates
--------------
After system updates, such as updates to a new Ubuntu or Debian release, you might be using a new Python version.
That's great, but requires some adjustments. First, adjust any old version paths in your nginx configuration file.
Then, re-create your Python environment::
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 freeze > /tmp/pip-backup.txt
$ rm -rf /var/pretix/venv
$ python3 -m venv /var/pretix/venv
$ source /var/pretix/venv/bin/activate
(venv)$ pip3 install -U pip wheel setuptools
(venv)$ pip3 install -r /tmp/pip-backup.txt
Then, proceed like after any plugin installation::
(venv)$ python -m pretix migrate
(venv)$ python -m pretix rebuild
(venv)$ python -m pretix updatestyles
# systemctl restart pretix-web pretix-worker
.. _Postfix: https://www.digitalocean.com/community/tutorials/how-to-install-and-configure-postfix-as-a-send-only-smtp-server-on-ubuntu-16-04
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
+9 -4
View File
@@ -48,10 +48,11 @@ Possible permissions are:
Compatibility
-------------
We currently see pretix' API as a beta-stage feature. We therefore do not give any guarantees
for compatibility between feature releases of pretix (such as 1.5 and 1.6). However, as always,
we try not to break things when we don't need to. Any backwards-incompatible changes will be
prominently noted in the release notes.
We try to avoid any breaking changes to our API to avoid hassle on your end. If possible, we'll
build new features in a way that keeps all pre-existing API usage unchanged. In some cases,
this might not be possible or only possible with restrictions. In these case, any
backwards-incompatible changes will be prominently noted in the "Changes to the REST API"
section of our release notes. If possible, we will announce them multiple releases in advance.
We treat the following types of changes as *backwards-compatible* so we ask you to make sure
that your clients can deal with them properly:
@@ -60,6 +61,7 @@ that your clients can deal with them properly:
* Support of new HTTP methods for a given API endpoint
* Support of new query parameters for a given API endpoint
* New fields contained in API responses
* New possible values of enumeration-like fields
* Response body structure or message texts on failed requests (``4xx``, ``5xx`` response codes)
We treat the following types of changes as *backwards-incompatible*:
@@ -190,6 +192,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
specifiers in requests
(see below).
Date range *either* two dates separated ``2022-03-18/2022-03-23``, ``2022-03-18/``,
by ``/`` *or* the name of a ``/2022-03-23``, ``week_this``, ``week_next``,
defined range. ``month_this``
===================== ============================ ===================================
Query parameters
+2
View File
@@ -98,6 +98,8 @@ Endpoints
:query string ends_after: Exclude all check-in lists attached to a sub-event that is already in the past at the given time.
:query string expand: Expand a field into a full object. Currently only ``subevent`` is supported. Can be passed multiple times.
:query string exclude: Exclude a field from the output, e.g. ``checkin_count``. Can be used as a performance optimization. Can be passed multiple times.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``id``, ``name``, and ``subevent__date_from``,
Default: ``subevent__date_from,name``
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
+16 -5
View File
@@ -43,8 +43,13 @@ available_until datetime The last date t
hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
meta_data object Values set for event-specific meta data parameters.
===================================== ========================== =======================================================
.. versionchanged:: 4.16
The ``meta_data`` attribute has been added.
Endpoints
---------
@@ -94,6 +99,7 @@ Endpoints
"default_price": "223.00",
"price": 223.0,
"original_price": null,
"meta_data": {}
},
{
"id": 3,
@@ -108,7 +114,8 @@ Endpoints
"description": {},
"position": 1,
"default_price": null,
"price": 15.0
"price": 15.0,
"meta_data": {}
}
]
}
@@ -161,7 +168,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
"position": 0,
"meta_data": {}
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -198,7 +206,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
"position": 0,
"meta_data": {}
}
**Example response**:
@@ -225,7 +234,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 0
"position": 0,
"meta_data": {}
}
:param organizer: The ``slug`` field of the organizer of the event/item to create a variation for
@@ -283,7 +293,8 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"position": 1
"position": 1,
"meta_data": {}
}
:param organizer: The ``slug`` field of the organizer to modify
+26
View File
@@ -35,6 +35,12 @@ tax_rule integer The internal ID
admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others
(such as add-ons or merchandise).
personalized boolean ``true`` for items that require personalization according
to event settings. Only affects system-level fields, not
custom questions. Currently only allowed for products with
``admission`` set to ``true``. For backwards compatibility,
when creating new items and this field is not given, it defaults
to the same value as ``admission``.
position integer An integer, used for sorting
picture file A product picture to be displayed in the shop
(can be ``null``).
@@ -123,6 +129,7 @@ variations list of objects A list with one
├ hide_without_voucher boolean If ``true``, this variation is only shown during the voucher
redemption process, but not in the normal shop
frontend.
├ meta_data object Values set for event-specific meta data parameters.
└ position integer An integer, used for sorting
addons list of objects Definition of add-ons that can be chosen for this item.
Only writable during creation,
@@ -155,6 +162,10 @@ meta_data object Values set for
The attributes ``require_membership_hidden`` attribute has been added.
.. versionchanged:: 4.16
The ``variations[x].meta_data`` attribute has been added. The ``personalized`` attribute has been added.
Notes
-----
@@ -208,6 +219,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -247,6 +259,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -262,6 +275,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -322,6 +336,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -361,6 +376,7 @@ Endpoints
"available_from": null,
"available_until": null,
"hide_without_voucher": false,
"meta_data": {},
"position": 0
},
{
@@ -376,6 +392,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -417,6 +434,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -455,6 +473,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -470,6 +489,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -499,6 +519,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -538,6 +559,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -553,6 +575,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
@@ -613,6 +636,7 @@ Endpoints
"tax_rate": "0.00",
"tax_rule": 1,
"admission": false,
"personalized": false,
"issue_giftcard": false,
"meta_data": {},
"position": 0,
@@ -652,6 +676,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 0
},
{
@@ -667,6 +692,7 @@ Endpoints
"available_until": null,
"hide_without_voucher": false,
"description": null,
"meta_data": {},
"position": 1
}
],
+4
View File
@@ -76,6 +76,10 @@ The exporter class
This is an abstract attribute, you **must** override this!
.. autoattribute:: description
.. autoattribute:: category
.. autoattribute:: export_form_fields
.. automethod:: render
+1
View File
@@ -91,6 +91,7 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal content ID
title multi-lingual string The content title (required)
internal_name string An optional name that is only used in the backend
content_type string The type of content, valid values are ``webinar``, ``video``, ``livestream``, ``link``, ``file``
url string The location of the digital content
file file A downloadable file. Either ``url`` or ``file`` must be ``null``.
+127 -4
View File
@@ -17,9 +17,13 @@ Field Type Description
id integer Internal layout ID
name string Internal layout description
default boolean ``true`` if this is the default layout
layout object Layout specification for libpretixprint
layout list Dynamic layout specification. Each list element
corresponds to one dynamic element of the layout.
The current version of the schema in use can be found
`here`_.
Submitting invalid content can lead to application errors.
background URL Background PDF file
item_assignments list of objects Products this layout is assigned to
item_assignments list of objects Products this layout is assigned to (currently read-only)
├ sales_channel string Sales channel (defaults to ``web``).
└ item integer Item ID
===================================== ========================== =======================================================
@@ -58,7 +62,7 @@ Endpoints
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"background": null,
"item_assignments": []
}
]
@@ -96,7 +100,7 @@ Endpoints
"name": "Default layout",
"default": true,
"layout": {…},
"background": {},
"background": null,
"item_assignments": []
}
@@ -147,3 +151,122 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/
Creates a new ticket layout
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "Default layout",
"default": true,
"layout": […],
"background": null,
"item_assignments": []
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": […],
"background": null,
"item_assignments": []
}
:param organizer: The ``slug`` field of the organizer of the event to create a layout for
:param event: The ``slug`` field of the event to create a layout for
:statuscode 201: no error
:statuscode 400: The layout could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
Update a layout. 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.
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "Default layout"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Default layout",
"default": true,
"layout": […],
"background": null,
"item_assignments": []
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the layout to modify
:statuscode 200: no error
:statuscode 400: The layout could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to change this resource.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/ticketlayouts/(id)/
Delete a layout.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/ticketlayouts/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param id: The ``id`` field of the layout to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/schema/pdf-layout.schema.json
+1
View File
@@ -97,6 +97,7 @@ overpayment
param
passphrase
percental
personalization
pluggable
positionid
pre
+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.15.0"
__version__ = "4.16.0.dev0"
@@ -0,0 +1,77 @@
# Generated by Django 3.2.16 on 2022-12-17 18:47
import uuid
import django.db.models.deletion
import oauth2_provider.generators
import oauth2_provider.models
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0226_itemvariationmetavalue'),
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
('pretixapi', '0008_webhookcallretry'),
]
run_before = [
('oauth2_provider', '0002_auto_20190406_1805'),
]
operations = [
migrations.AddField(
model_name='oauthapplication',
name='algorithm',
field=models.CharField(default='', max_length=5),
),
migrations.AddField(
model_name='oauthgrant',
name='claims',
field=models.TextField(default=''),
preserve_default=False,
),
migrations.AddField(
model_name='oauthgrant',
name='code_challenge',
field=models.CharField(default='', max_length=128),
),
migrations.AddField(
model_name='oauthgrant',
name='code_challenge_method',
field=models.CharField(default='', max_length=10),
),
migrations.AddField(
model_name='oauthgrant',
name='nonce',
field=models.CharField(default='', max_length=255),
),
migrations.AlterField(
model_name='oauthapplication',
name='client_secret',
field=oauth2_provider.models.ClientSecretField(db_index=True, default=oauth2_provider.generators.generate_client_secret, max_length=255),
),
migrations.CreateModel(
name='OAuthIDToken',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('jti', models.UUIDField(default=uuid.uuid4, unique=True)),
('expires', models.DateTimeField()),
('scope', models.TextField()),
('created', models.DateTimeField(auto_now_add=True)),
('updated', models.DateTimeField(auto_now=True)),
('application', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL)),
('organizers', models.ManyToManyField(to='pretixbase.Organizer')),
('user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='pretixapi_oauthidtoken', to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
migrations.AddField(
model_name='oauthaccesstoken',
name='id_token',
field=models.OneToOneField(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='access_token', to='pretixapi.oauthidtoken'),
),
]
+17 -3
View File
@@ -29,8 +29,8 @@ from oauth2_provider.generators import (
generate_client_id, generate_client_secret,
)
from oauth2_provider.models import (
AbstractAccessToken, AbstractApplication, AbstractGrant,
AbstractRefreshToken,
AbstractAccessToken, AbstractApplication, AbstractGrant, AbstractIDToken,
AbstractRefreshToken, ClientSecretField,
)
from oauth2_provider.validators import URIValidator
@@ -46,7 +46,7 @@ class OAuthApplication(AbstractApplication):
verbose_name=_("Client ID"),
max_length=100, unique=True, default=generate_client_id, db_index=True
)
client_secret = models.CharField(
client_secret = ClientSecretField(
verbose_name=_("Client secret"),
max_length=255, blank=False, default=generate_client_secret, db_index=True
)
@@ -67,12 +67,26 @@ class OAuthGrant(AbstractGrant):
redirect_uri = models.CharField(max_length=2500) # Only 255 in AbstractGrant, which caused problems
class OAuthIDToken(AbstractIDToken):
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE,
)
organizers = models.ManyToManyField('pretixbase.Organizer')
class OAuthAccessToken(AbstractAccessToken):
source_refresh_token = models.OneToOneField(
# unique=True implied by the OneToOneField
'OAuthRefreshToken', on_delete=models.SET_NULL, blank=True, null=True,
related_name="refreshed_access_token"
)
id_token = models.OneToOneField(
OAuthIDToken,
on_delete=models.CASCADE,
blank=True,
null=True,
related_name="access_token",
)
application = models.ForeignKey(
OAuthApplication, on_delete=models.CASCADE, blank=True, null=True,
)
+43
View File
@@ -22,8 +22,10 @@
from django import forms
from django.http import QueryDict
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
class FormFieldWrapperField(serializers.Field):
@@ -142,6 +144,12 @@ class JobRunSerializer(serializers.Serializer):
allow_null=not v.required,
validators=v.validators,
)
elif isinstance(v, DateFrameField):
self.fields[k] = SerializerDateFrameField(
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
else:
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required)
@@ -151,5 +159,40 @@ class JobRunSerializer(serializers.Serializer):
for k, v in self.fields.items():
if isinstance(v, serializers.ManyRelatedField) and k not in data:
data[k] = []
for fk in self.fields.keys():
# Backwards compatibility for exports that used to take e.g. (date_from, date_to) or (event_date_from, event_date_to)
# and now only take date_range.
if fk.endswith("_range") and isinstance(self.fields[fk], SerializerDateFrameField) and fk not in data:
if fk.replace("_range", "_from") in data:
d_from = data.pop(fk.replace("_range", "_from"))
if d_from:
d_from = serializers.DateField().to_internal_value(d_from)
else:
d_from = None
if fk.replace("_range", "_to") in data:
d_to = data.pop(fk.replace("_range", "_to"))
if d_to:
d_to = serializers.DateField().to_internal_value(d_to)
else:
d_to = None
data[fk] = f'{d_from.isoformat() if d_from else ""}/{d_to.isoformat() if d_to else ""}'
data = super().to_internal_value(data)
return data
def is_valid(self, raise_exception=False):
super().is_valid(raise_exception=raise_exception)
fields_keys = set(self.fields.keys())
input_keys = set(self.initial_data.keys())
additional_fields = input_keys - fields_keys
if bool(additional_fields):
self._errors['fields'] = ['Additional fields not allowed: {}.'.format(list(additional_fields))]
if self._errors and raise_exception:
raise ValidationError(self.errors)
return not bool(self._errors)
+86 -4
View File
@@ -47,13 +47,14 @@ from pretix.api.serializers.fields import UploadedFileField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue, ItemVariation,
Question, QuestionOption, Quota,
ItemVariationMetaValue, Question, QuestionOption, Quota,
)
class InlineItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
class Meta:
model = ItemVariation
@@ -61,16 +62,23 @@ class InlineItemVariationSerializer(I18nAwareModelSerializer):
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = lazy(lambda: self.context['event'].organizer.membership_types.all(), QuerySet)
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.parent.parent.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
class ItemVariationSerializer(I18nAwareModelSerializer):
price = serializers.DecimalField(read_only=True, decimal_places=2, max_digits=10,
coerce_to_string=True)
meta_data = MetaDataField(required=False, source='*')
class Meta:
model = ItemVariation
@@ -78,12 +86,67 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
'position', 'default_price', 'price', 'original_price', 'require_approval',
'require_membership', 'require_membership_types',
'require_membership_hidden', 'available_from', 'available_until',
'sales_channels', 'hide_without_voucher',)
'sales_channels', 'hide_without_voucher', 'meta_data')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['require_membership_types'].queryset = self.context['event'].organizer.membership_types.all()
@transaction.atomic
def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None)
require_membership_types = validated_data.pop('require_membership_types', [])
variation = ItemVariation.objects.create(**validated_data)
if require_membership_types:
variation.require_membership_types.add(*require_membership_types)
# Meta data
if meta_data is not None:
for key, value in meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=variation
)
return variation
@cached_property
def item_meta_properties(self):
return {
p.name: p for p in self.context['request'].event.item_meta_properties.all()
}
def validate_meta_data(self, value):
for key in value['meta_data'].keys():
if key not in self.item_meta_properties:
raise ValidationError(_('Item meta data property \'{name}\' does not exist.').format(name=key))
return value
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
variation = super().update(instance, validated_data)
# Meta data
if meta_data is not None:
current = {mv.property: mv for mv in variation.meta_values.select_related('property')}
for key, value in meta_data.items():
prop = self.item_meta_properties.get(key)
if prop in current:
current[prop].value = value
current[prop].save()
else:
variation.meta_values.create(
property=self.item_meta_properties.get(key),
value=value
)
for prop, current_object in current.items():
if prop.name not in meta_data:
current_object.delete()
return variation
class InlineItemBundleSerializer(serializers.ModelSerializer):
class Meta:
@@ -171,7 +234,7 @@ class ItemSerializer(I18nAwareModelSerializer):
class Meta:
model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission',
'default_price', 'free_price', 'tax_rate', 'tax_rule', 'admission', 'personalized',
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
@@ -199,6 +262,15 @@ 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'))
if data.get('personalized') and not data.get('admission'):
raise ValidationError(_('Only admission products can currently be personalized.'))
if data.get('admission') and 'personalized' not in data and not self.instance:
# Backwards compatibility
data['personalized'] = True
elif 'admission' in data and not data['admission']:
data['personalized'] = False
if data.get('issue_giftcard'):
if data.get('tax_rule') and data.get('tax_rule').rate > 0:
raise ValidationError(
@@ -263,9 +335,19 @@ class ItemSerializer(I18nAwareModelSerializer):
for variation_data in variations_data:
require_membership_types = variation_data.pop('require_membership_types', [])
var_meta_data = variation_data.pop('meta_data', {})
v = ItemVariation.objects.create(item=item, **variation_data)
if require_membership_types:
v.require_membership_types.add(*require_membership_types)
if var_meta_data is not None:
for key, value in var_meta_data.items():
ItemVariationMetaValue.objects.create(
property=self.item_meta_properties.get(key),
value=value,
variation=v
)
for addon_data in addons_data:
ItemAddOn.objects.create(base_item=item, **addon_data)
for bundle_data in bundles_data:
+31 -5
View File
@@ -118,6 +118,10 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'name': ['Do not specify name if you specified name_parts.']}
)
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
raise ValidationError({'name_parts': ['Invalid data type']})
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
@@ -371,10 +375,19 @@ class PdfDataSerializer(serializers.Field):
for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v
if not hasattr(instance.item, '_cached_meta_data'):
instance.item._cached_meta_data = instance.item.meta_data
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + 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:
if not hasattr(instance.item, '_cached_meta_data'):
instance.item._cached_meta_data = instance.item.meta_data
for k, v in instance.item._cached_meta_data.items():
res['itemmeta:' + k] = v
res['images'] = {}
@@ -627,7 +640,7 @@ class OrderSerializer(I18nAwareModelSerializer):
for fname, field in list(self.fields.items()):
if fname in includes:
continue
elif hasattr(field, 'child'):
elif hasattr(field, 'child'): # Nested list serializers
found_any = False
for childfname, childfield in list(field.child.fields.items()):
if f'{fname}.{childfname}' not in includes:
@@ -636,6 +649,15 @@ class OrderSerializer(I18nAwareModelSerializer):
found_any = True
if not found_any:
self.fields.pop(fname)
elif isinstance(field, serializers.Serializer): # Nested serializers
found_any = False
for childfname, childfield in list(field.fields.items()):
if f'{fname}.{childfname}' not in includes:
field.fields.pop(childfname)
else:
found_any = True
if not found_any:
self.fields.pop(fname)
else:
self.fields.pop(fname)
@@ -823,6 +845,10 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']}
)
if data.get('attendee_name_parts') and not isinstance(data.get('attendee_name_parts'), dict):
raise ValidationError({'attendee_name_parts': ['Invalid data type']})
if data.get('attendee_name_parts') and '_scheme' not in data.get('attendee_name_parts'):
data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
+4 -2
View File
@@ -158,12 +158,14 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
a.question_id: a for a in instance.answers.all()
}
for answ_data in answers_data:
if not answ_data.get('answer'):
continue
options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk]
if isinstance(answ_data['answer'], File):
if isinstance(answ_data.get('answer'), File):
a.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
@@ -173,7 +175,7 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
setattr(a, attr, value)
a.save()
else:
if isinstance(answ_data['answer'], File):
if isinstance(answ_data.get('answer'), File):
an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False)
+7
View File
@@ -79,6 +79,13 @@ class CustomerSerializer(I18nAwareModelSerializer):
validated_data['external_identifier'] = instance.external_identifier
return super().update(instance, validated_data)
def validate(self, data):
if data.get('name_parts') and not isinstance(data.get('name_parts'), dict):
raise ValidationError({'name_parts': ['Invalid data type']})
if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = self.context['request'].organizer.settings.name_scheme
return data
class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True)
+2 -1
View File
@@ -35,7 +35,8 @@
import importlib
from django.apps import apps
from django.conf.urls import include, re_path
from django.conf.urls import re_path
from django.urls import include
from rest_framework import routers
from pretix.api.views import cart
+3 -1
View File
@@ -93,8 +93,10 @@ with scopes_disabled():
class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
queryset = CheckinList.objects.none()
filter_backends = (DjangoFilterBackend,)
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filterset_class = CheckinListFilter
ordering = ('subevent__date_from', 'name')
ordering_fields = ('subevent__date_from', 'id', 'name',)
def _get_permission_name(self, request):
if request.path.endswith('/failed_checkins/'):
+8 -2
View File
@@ -84,7 +84,9 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
def get_queryset(self):
return self.request.event.items.select_related('tax_rule').prefetch_related(
'variations', 'addons', 'bundles', 'meta_values'
'variations', 'addons', 'bundles', 'meta_values', 'meta_values__property',
'variations__meta_values', 'variations__meta_values__property',
'require_membership_types', 'variations__require_membership_types',
).all()
def perform_create(self, serializer):
@@ -147,7 +149,11 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
return get_object_or_404(Item, pk=self.kwargs['item'], event=self.request.event)
def get_queryset(self):
return self.item.variations.all()
return self.item.variations.all().prefetch_related(
'meta_values',
'meta_values__property',
'require_membership_types'
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
+12 -5
View File
@@ -65,9 +65,10 @@ from pretix.api.views import RichOrderingFilter
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Checkin, Device, EventMetaValue,
Invoice, InvoiceAddress, ItemMetaValue, Order, OrderFee, OrderPayment,
OrderPosition, OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule,
TeamAPIToken, generate_secret,
Invoice, InvoiceAddress, ItemMetaValue, ItemVariation,
ItemVariationMetaValue, Order, OrderFee, OrderPayment, OrderPosition,
OrderRefund, Quota, SubEvent, SubEventMetaValue, TaxRule, TeamAPIToken,
generate_secret,
)
from pretix.base.models.orders import QuestionAnswer, RevokedTicketSecret
from pretix.base.payment import PaymentException
@@ -232,7 +233,9 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch('item', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)),
'variation',
Prefetch('variation', queryset=ItemVariation.objects.prefetch_related(
Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'), to_attr='meta_values_cached')
)),
'answers', 'answers__options', 'answers__question',
'item__category',
'addon_to__answers', 'addon_to__answers__options', 'addon_to__answers__question',
@@ -999,7 +1002,11 @@ class OrderPositionViewSet(viewsets.ModelViewSet):
Prefetch('meta_values', ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
'variation', 'answers', 'answers__options', 'answers__question',
Prefetch('variation', queryset=self.request.event.items.prefetch_related(
Prefetch('meta_values', ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached')
)),
'answers', 'answers__options', 'answers__question',
'item__category',
Prefetch('subevent', queryset=self.request.event.subevents.prefetch_related(
Prefetch('meta_values', to_attr='meta_values_cached',
+11
View File
@@ -21,6 +21,7 @@
#
import datetime
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.timezone import now
from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import SessionAuthentication
@@ -33,6 +34,9 @@ from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.api.auth.permission import AnyAuthenticatedClientPermission
from pretix.api.auth.token import TeamTokenAuthentication
from pretix.base.models import CachedFile
from pretix.helpers.images import (
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
)
ALLOWED_TYPES = {
'image/gif': {'.gif'},
@@ -61,6 +65,13 @@ class UploadView(APIView):
name=file_obj.name,
type=content_type
))
if content_type in IMAGE_TYPES:
try:
validate_uploaded_file_for_valid_image(file_obj)
except DjangoValidationError as e:
raise ValidationError(e.message)
cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1),
date=now(),
+225
View File
@@ -0,0 +1,225 @@
#
# 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 collections import defaultdict
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from localflavor.ar.forms import ARPostalCodeField
from localflavor.at.forms import ATZipCodeField
from localflavor.au.forms import AUPostCodeField
from localflavor.be.forms import BEPostalCodeField
from localflavor.br.forms import BRZipCodeField
from localflavor.ca.forms import CAPostalCodeField
from localflavor.ch.forms import CHZipCodeField
from localflavor.cn.forms import CNPostCodeField
from localflavor.cu.forms import CUPostalCodeField
from localflavor.cz.forms import CZPostalCodeField
from localflavor.de.forms import DEZipCodeField
from localflavor.dk.forms import DKPostalCodeField
from localflavor.ee.forms import EEZipCodeField
from localflavor.es.forms import ESPostalCodeField
from localflavor.fi.forms import FIZipCodeField
from localflavor.fr.forms import FRZipCodeField
from localflavor.gb.forms import GBPostcodeField
from localflavor.gr.forms import GRPostalCodeField
from localflavor.hr.forms import HRPostalCodeField
from localflavor.ie.forms import EircodeField
from localflavor.il.forms import ILPostalCodeField
from localflavor.in_.forms import INZipCodeField
from localflavor.ir.forms import IRPostalCodeField
from localflavor.is_.is_postalcodes import IS_POSTALCODES
from localflavor.it.forms import ITZipCodeField
from localflavor.jp.forms import JPPostalCodeField
from localflavor.lt.forms import LTPostalCodeField
from localflavor.lv.forms import LVPostalCodeField
from localflavor.ma.forms import MAPostalCodeField
from localflavor.mt.forms import MTPostalCodeField
from localflavor.mx.forms import MXZipCodeField
from localflavor.nl.forms import NLZipCodeField
from localflavor.no.forms import NOZipCodeField
from localflavor.nz.forms import NZPostCodeField
from localflavor.pk.forms import PKPostCodeField
from localflavor.pl.forms import PLPostalCodeField
from localflavor.pt.forms import PTZipCodeField
from localflavor.ro.forms import ROPostalCodeField
from localflavor.ru.forms import RUPostalCodeField
from localflavor.se.forms import SEPostalCodeField
from localflavor.sg.forms import SGPostCodeField
from localflavor.si.si_postalcodes import SI_POSTALCODES
from localflavor.sk.forms import SKPostalCodeField
from localflavor.tr.forms import TRPostalCodeField
from localflavor.ua.forms import UAPostalCodeField
from localflavor.us.forms import USZipCodeField
from localflavor.za.forms import ZAPostCodeField
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
_validator_classes = defaultdict(list)
COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED = {
# We don't presume this for countries we don't have knowledge about, there are countries in the
# world e.g. without zipcodes
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
'GB', 'GR', 'HR', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX', 'NL',
'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
}
def validate_address(address: dict, all_optional=False):
"""
:param address: A dictionary with at least the entries ``street``, ``zipcode``, ``city``, ``country``,
``state``
:return: The dictionary, possibly with changes
"""
if not address.get('street') and not address.get('zipcode') and not address.get('city'):
# Consider the actual address part to be empty, no further validation necessary, if the
# address should be required, it's the callers job to validate that at least one of these
# fields is filled
return address
if not address.get('country'):
raise ValidationError({'country': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS and not address.get('state') and not all_optional:
raise ValidationError({'state': [_('This field is required.')]})
if str(address['country']) in COUNTRIES_WITH_STREET_ZIPCODE_AND_CITY_REQUIRED and not all_optional:
for f in ('street', 'zipcode', 'city'):
if not address.get(f):
raise ValidationError({f: [_('This field is required.')]})
for klass in _validator_classes[str(address['country'])]:
validator = klass()
try:
if address.get('zipcode'):
address['zipcode'] = validator.validate_zipcode(address['zipcode'])
except ValidationError as e:
raise ValidationError({'zipcode': list(e)})
return address
def register_validator_for(country):
def inner(klass):
_validator_classes[country].append(klass)
return klass
return inner
class BaseValidator:
required_fields = []
def validate_zipcode(self, value):
return value
"""
Currently, mostly have validators that are auto-generated from django-localflavor
but custom ones can be added like this:
@register_validator_for('DE')
class DEValidator(BaseValidator):
def validate_zipcode(value):
return value
In the future, we can also add additional methods to validate that e.g. a city
is plausible for a given zip code.
"""
_zip_code_fields = {
'AR': ARPostalCodeField,
'AT': ATZipCodeField,
'AU': AUPostCodeField,
'BE': BEPostalCodeField,
'BR': BRZipCodeField,
'CA': CAPostalCodeField,
'CH': CHZipCodeField,
'CN': CNPostCodeField,
'CU': CUPostalCodeField,
'CZ': CZPostalCodeField,
'DE': DEZipCodeField,
'DK': DKPostalCodeField,
'EE': EEZipCodeField,
'ES': ESPostalCodeField,
'FI': FIZipCodeField,
'FR': FRZipCodeField,
'GB': GBPostcodeField,
'GR': GRPostalCodeField,
'HR': HRPostalCodeField,
'IE': EircodeField,
'IL': ILPostalCodeField,
'IN': INZipCodeField,
'IR': IRPostalCodeField,
'IT': ITZipCodeField,
'JP': JPPostalCodeField,
'LT': LTPostalCodeField,
'LV': LVPostalCodeField,
'MA': MAPostalCodeField,
'MT': MTPostalCodeField,
'MX': MXZipCodeField,
'NL': NLZipCodeField,
'NO': NOZipCodeField,
'NZ': NZPostCodeField,
'PK': PKPostCodeField,
'PL': PLPostalCodeField,
'PT': PTZipCodeField,
'RO': ROPostalCodeField,
'RU': RUPostalCodeField,
'SE': SEPostalCodeField,
'SG': SGPostCodeField,
'SK': SKPostalCodeField,
'TR': TRPostalCodeField,
'UA': UAPostalCodeField,
'US': USZipCodeField,
'ZA': ZAPostCodeField,
}
def _generate_class_from_zipcode_field(field_class):
class _GeneratedValidator(BaseValidator):
def validate_zipcode(self, value):
return field_class().clean(value)
return _GeneratedValidator
for cc, field_class in _zip_code_fields.items():
register_validator_for(cc)(_generate_class_from_zipcode_field(field_class))
@register_validator_for('IS')
class ISValidator(BaseValidator):
def validate_zipcode(self, value):
if value not in [entry[0] for entry in IS_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXX.'), code='invalid')
return value
@register_validator_for('SI')
class SIValidator(BaseValidator):
def validate_zipcode(self, value):
try:
if int(value) not in [entry[0] for entry in SI_POSTALCODES]:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
except ValueError:
raise ValidationError(_('Enter a postal code in the format XXXX.'), code='invalid')
return value
+15 -7
View File
@@ -381,6 +381,14 @@ def base_placeholders(sender, **kwargs):
SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
),
SimpleFunctionalMailTextPlaceholder(
'order_email', ['order'], lambda order: order.email, 'john@example.org'
),
SimpleFunctionalMailTextPlaceholder(
'invoice_number', ['invoice'],
lambda invoice: invoice.full_invoice_no,
f'{sender.settings.invoice_numbers_prefix or (sender.slug.upper() + "-")}00000'
),
SimpleFunctionalMailTextPlaceholder(
'refund_amount', ['event_or_subevent', 'refund_amount'],
lambda event_or_subevent, refund_amount: LazyCurrencyNumber(refund_amount, event_or_subevent.currency),
@@ -512,20 +520,20 @@ def base_placeholders(sender, **kwargs):
lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
),
SimpleFunctionalMailTextPlaceholder(
'url_remove', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
'url_remove', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.waitinglist.remove'
) + '?voucher=' + waiting_list_entry.voucher.code,
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.waitinglist.remove',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalMailTextPlaceholder(
'url', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
'url', ['waiting_list_voucher', 'event'],
lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_entry.voucher.code,
) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.redeem',
@@ -580,7 +588,7 @@ def base_placeholders(sender, **kwargs):
_('Sample Admission Ticket')
),
SimpleFunctionalMailTextPlaceholder(
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
'code', ['waiting_list_voucher'], lambda waiting_list_voucher: waiting_list_voucher.code,
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalMailTextPlaceholder(
+22 -1
View File
@@ -36,7 +36,7 @@ import io
import tempfile
from collections import OrderedDict, namedtuple
from decimal import Decimal
from typing import Tuple
from typing import Optional, Tuple
import pytz
from defusedcsv import csv
@@ -84,6 +84,27 @@ class BaseExporter:
"""
raise NotImplementedError() # NOQA
@property
def description(self) -> str:
"""
A description for this exporter.
"""
return ""
@property
def category(self) -> Optional[str]:
"""
A category name for this exporter, or ``None``.
"""
return None
@property
def featured(self) -> bool:
"""
If ``True``, this exporter will be highlighted.
"""
return False
@property
def identifier(self) -> str:
"""
+5 -2
View File
@@ -39,7 +39,7 @@ from zipfile import ZipFile
from django import forms
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import QuestionAnswer
@@ -49,7 +49,10 @@ from ..signals import register_data_exporters
class AnswerFilesExporter(BaseExporter):
identifier = 'answerfiles'
verbose_name = _('Answers to file upload questions')
verbose_name = _('Question answer file uploads')
category = pgettext_lazy('export_category', 'Order data')
description = _('Download a ZIP file including all files that have been uploaded by your customers while creating '
'an order.')
@property
def export_form_fields(self):
+3 -1
View File
@@ -36,7 +36,7 @@ from collections import OrderedDict
from django.dispatch import receiver
from django.utils.timezone import get_current_timezone
from django.utils.translation import gettext as _, gettext_lazy
from django.utils.translation import gettext as _, gettext_lazy, pgettext_lazy
from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -48,6 +48,8 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts')
organizer_required_permission = 'can_manage_customers'
category = pgettext_lazy('export_category', 'Customer accounts')
description = gettext_lazy('Download a spreadsheet of all currently registered customer accounts.')
@property
def additional_form_fields(self):
+17 -28
View File
@@ -23,22 +23,24 @@ import json
from collections import OrderedDict
from decimal import Decimal
import dateutil
from django import forms
from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver
from django.utils.translation import gettext, gettext_lazy
from django.utils.timezone import now
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
from pretix.base.i18n import language
from pretix.base.models import Invoice, OrderPayment
from ..exporter import BaseExporter
from ..signals import register_data_exporters
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class DekodiNREIExporter(BaseExporter):
identifier = 'dekodi_nrei'
verbose_name = 'dekodi NREI (JSON)'
category = pgettext_lazy('export_category', 'Invoices')
description = gettext_lazy("Download invoices in a format that can be used by the dekodi NREI conversion software.")
# Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
@@ -113,7 +115,7 @@ class DekodiNREIExporter(BaseExporter):
'PTNo14': p.info_data.get('reference') or '',
'PTNo15': p.full_id or '',
})
elif p.provider.startswith('stripe'):
elif p.provider and p.provider.startswith('stripe'):
src = p.info_data.get("source", p.info_data)
payments.append({
'PTID': '81',
@@ -192,17 +194,12 @@ class DekodiNREIExporter(BaseExporter):
def render(self, form_data):
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__gte=date_value)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__lte=date_value)
if form_data.get('date_range'):
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
if d_start:
qs = qs.filter(date__gte=d_start)
if d_end:
qs = qs.filter(date__lte=d_end)
jo = {
'Format': 'NREI',
@@ -218,22 +215,14 @@ class DekodiNREIExporter(BaseExporter):
def export_form_fields(self):
return OrderedDict(
[
('date_from',
forms.DateField(
label=gettext_lazy('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('date_range',
DateFrameField(
label=gettext_lazy('Date range'),
include_future_frames=False,
required=False,
help_text=gettext_lazy('Only include invoices issued on or after this date. Note that the invoice date does '
help_text=gettext_lazy('Only include invoices issued in this time frame. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
('date_to',
forms.DateField(
label=gettext_lazy('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=gettext_lazy('Only include invoices issued on or before this date. Note that the invoice date '
'does not always correspond to the order or payment date.')
)),
]
)
+3 -1
View File
@@ -35,7 +35,7 @@
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from ...control.forms.filter import get_all_payment_providers
from ..exporter import ListExporter
@@ -45,6 +45,8 @@ from ..signals import register_multievent_data_exporters
class EventDataExporter(ListExporter):
identifier = 'eventdata'
verbose_name = _('Event data')
category = pgettext_lazy('export_category', 'Event data')
description = _('Download a spreadsheet with information on all events in this organizer account.')
@cached_property
def providers(self):
+22 -25
View File
@@ -38,13 +38,15 @@ from collections import OrderedDict
from decimal import Decimal
from zipfile import ZipFile
import dateutil.parser
from django import forms
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.translation import gettext, gettext_lazy as _, pgettext
from django.utils.timezone import now
from django.utils.translation import (
gettext, gettext_lazy as _, pgettext, pgettext_lazy,
)
from pretix.base.models import Invoice, InvoiceLine, OrderPayment
@@ -57,30 +59,24 @@ from ..services.invoices import invoice_pdf_task
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class InvoiceExporterMixin:
category = pgettext_lazy('export_category', 'Invoices')
@property
def invoice_exporter_form_fields(self):
return OrderedDict(
[
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('date_range',
DateFrameField(
label=_('Date range'),
include_future_frames=False,
required=False,
help_text=_('Only include invoices issued on or after this date. Note that the invoice date does '
help_text=_('Only include invoices issued in this time frame. Note that the invoice date does '
'not always correspond to the order or payment date.')
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include invoices issued on or before this date. Note that the invoice date '
'does not always correspond to the order or payment date.')
)),
('payment_provider',
forms.ChoiceField(
label=_('Payment provider'),
@@ -112,16 +108,12 @@ class InvoiceExporterMixin:
)
)
qs = qs.filter(has_payment_with_provider=1)
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__gte=date_value)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(date__lte=date_value)
if form_data.get('date_range'):
d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
if d_start:
qs = qs.filter(date__gte=d_start)
if d_end:
qs = qs.filter(date__lte=d_end)
return qs
@@ -129,6 +121,7 @@ class InvoiceExporterMixin:
class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
identifier = 'invoices'
verbose_name = _('All invoices')
description = _('Download all invoices created by the system as a ZIP file of PDF files.')
def render(self, form_data: dict, output_file=None):
qs = self.invoices_queryset(form_data).filter(shredded=False)
@@ -180,6 +173,10 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
identifier = 'invoicedata'
verbose_name = _('Invoice data')
description = _('Download a spreadsheet with the data of all invoices created by the system. The spreadsheet '
'includes two sheets, one with a line for every invoice, and one with a line for every position of '
'every invoice.')
featured = True
@property
def additional_form_fields(self):
+20 -5
View File
@@ -22,14 +22,14 @@
from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from openpyxl.styles import Alignment
from openpyxl.utils import get_column_letter
from ...helpers.safe_openpyxl import SafeCell
from ..channels import get_all_sales_channels
from ..exporter import ListExporter
from ..models import ItemMetaValue
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..signals import register_data_exporters
@@ -48,6 +48,8 @@ def _min(a1, a2):
class ItemDataExporter(ListExporter):
identifier = 'itemdata'
verbose_name = _('Product data')
category = pgettext_lazy('export_category', 'Product data')
description = _('Download a spreadsheet with details about all products and variations.')
def iterate_list(self, form_data):
locales = self.event.settings.locales
@@ -73,6 +75,7 @@ class ItemDataExporter(ListExporter):
_("Free price input"),
_("Sales tax"),
_("Is an admission ticket"),
_("Personalized ticket"),
_("Generate tickets"),
_("Waiting list"),
_("Available from"),
@@ -106,18 +109,27 @@ class ItemDataExporter(ListExporter):
yield row
for i in self.event.items.prefetch_related(
'variations',
Prefetch(
'meta_values',
ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
)
),
Prefetch(
'variations',
queryset=ItemVariation.objects.prefetch_related(
Prefetch(
'meta_values',
ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
),
),
).select_related('category', 'tax_rule'):
m = i.meta_data
vars = list(i.variations.all())
if vars:
for v in vars:
m = v.meta_data
row = [
i.pk,
v.pk,
@@ -135,6 +147,7 @@ class ItemDataExporter(ListExporter):
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission else "",
_("Yes") if i.personalized else "",
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
_("Yes") if i.allow_waitinglist else "",
date_format(_max(i.available_from, v.available_from).astimezone(self.timezone),
@@ -160,6 +173,7 @@ class ItemDataExporter(ListExporter):
yield row
else:
m = i.meta_data
row = [
i.pk,
"",
@@ -177,6 +191,7 @@ class ItemDataExporter(ListExporter):
_("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission else "",
_("Yes") if i.personalized else "",
_("Yes") if i.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
_("Yes") if i.allow_waitinglist else "",
date_format(i.available_from.astimezone(self.timezone),
+27 -2
View File
@@ -36,15 +36,22 @@ import json
from decimal import Decimal
from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Prefetch
from django.dispatch import receiver
from django.utils.functional import lazy
from django.utils.translation import gettext, gettext_lazy, pgettext_lazy
from ..exporter import BaseExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
from ..signals import register_data_exporters
class JSONExporter(BaseExporter):
identifier = 'json'
verbose_name = 'Order data (JSON)'
verbose_name = lazy(lambda *args: gettext('Order data') + ' (JSON)', str)()
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a structured JSON representation of all orders. This might be useful for the '
'import in third-party systems.')
def render(self, form_data):
jo = {
@@ -76,6 +83,7 @@ class JSONExporter(BaseExporter):
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'),
'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
'admission': item.admission,
'personalized': item.personalized,
'active': item.active,
'sales_channels': item.sales_channels,
'description': str(item.description),
@@ -106,9 +114,26 @@ class JSONExporter(BaseExporter):
'available_from': variation.available_from,
'available_until': variation.available_until,
'hide_without_voucher': variation.hide_without_voucher,
'meta_data': variation.meta_data,
} for variation in item.variations.all()
]
} for item in self.event.items.select_related('tax_rule').prefetch_related('variations')
} for item in self.event.items.select_related('tax_rule').prefetch_related(
Prefetch(
'meta_values',
ItemMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
Prefetch(
'variations',
queryset=ItemVariation.objects.prefetch_related(
Prefetch(
'meta_values',
ItemVariationMetaValue.objects.select_related('property'),
to_attr='meta_values_cached'
),
),
),
)
],
'questions': [
{
+3 -1
View File
@@ -36,7 +36,7 @@ from collections import OrderedDict
from django import forms
from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import OrderPosition
@@ -50,6 +50,8 @@ from ..signals import (
class MailExporter(BaseExporter):
identifier = 'mailaddrs'
verbose_name = _('Email addresses (text file)')
category = pgettext_lazy('export_category', 'Order data')
description = _("Download a text file with all email addresses collected either from buyers or from ticket holders.")
def render(self, form_data: dict):
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')
+78 -104
View File
@@ -33,10 +33,8 @@
# License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict
from datetime import date, datetime, time
from decimal import Decimal
import dateutil
import pytz
from django import forms
from django.db.models import (
@@ -46,8 +44,10 @@ from django.db.models import (
from django.db.models.functions import Coalesce
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now
from django.utils.translation import gettext as _, gettext_lazy, pgettext
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
from pretix.base.models import (
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
@@ -63,14 +63,24 @@ from ...helpers.iter import chunked_iterable
from ..exporter import (
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
)
from ..forms.widgets import SplitDateTimePickerWidget
from ..signals import (
register_data_exporters, register_multievent_data_exporters,
)
from ..timeframes import (
DateFrameField,
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
)
class OrderListExporter(MultiSheetListExporter):
identifier = 'orderlist'
verbose_name = gettext_lazy('Order data')
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a spreadsheet of all orders. The spreadsheet will include three sheets, one '
'with a line for every order, one with a line for every order position, and one with '
'a line for every additional fee charged in an order.')
featured = True
@cached_property
def providers(self):
@@ -105,41 +115,25 @@ class OrderListExporter(MultiSheetListExporter):
initial=False,
required=False
)),
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('date_range',
DateFrameField(
label=_('Date range'),
include_future_frames=False,
required=False,
help_text=_('Only include orders created on or after this date.')
help_text=_('Only include orders created within this date range.')
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
('event_date_range',
DateFrameField(
label=_('Event date'),
include_future_frames=True,
required=False,
help_text=_('Only include orders created on or before this date.')
)),
('event_date_from',
forms.DateField(
label=_('Start event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders including at least one ticket for a date on or after this date. '
'Will also include other dates in case of mixed orders!')
)),
('event_date_to',
forms.DateField(
label=_('End event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
help_text=_('Only include orders including at least one ticket for a date on or before this date. '
help_text=_('Only include orders including at least one ticket for a date in this range. '
'Will also include other dates in case of mixed orders!')
)),
]
d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents:
del d['event_date_from']
del d['event_date_to']
del d['event_date_range']
return d
def _get_all_payment_methods(self, qs):
@@ -182,45 +176,27 @@ class OrderListExporter(MultiSheetListExporter):
annotations = {}
filters = {}
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
if form_data.get('date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
if dt_start:
filters[f'{rel}datetime__gte'] = dt_start
if dt_end:
filters[f'{rel}datetime__lt'] = dt_end
filters[f'{rel}datetime__gte'] = datetime_value
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
filters[f'{rel}datetime__lte'] = datetime_value
if form_data.get('event_date_from'):
date_value = form_data.get('event_date_from')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
annotations['event_date_max'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_max__gte'] = datetime_value
if form_data.get('event_date_to'):
date_value = form_data.get('event_date_to')
if not isinstance(date_value, date):
date_value = dateutil.parser.parse(date_value).date()
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
annotations['event_date_min'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_min__lte'] = datetime_value
if form_data.get('event_date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['event_date_range'], self.timezone)
if dt_start:
annotations['event_date_max'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_max__gte'] = dt_start
if dt_end:
annotations['event_date_min'] = Case(
When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
default=F(f'{rel}event__date_from'),
)
filters['event_date_min__lt'] = dt_end
if filters:
return qs.annotate(**annotations).filter(**filters)
@@ -776,7 +752,10 @@ class OrderListExporter(MultiSheetListExporter):
class PaymentListExporter(ListExporter):
identifier = 'paymentlist'
verbose_name = gettext_lazy('Order payments and refunds')
verbose_name = gettext_lazy('Payments and refunds')
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a spreadsheet of all payments or refunds of every order.')
featured = True
@property
def additional_form_fields(self):
@@ -855,6 +834,8 @@ class PaymentListExporter(ListExporter):
class QuotaListExporter(ListExporter):
identifier = 'quotalist'
verbose_name = gettext_lazy('Quota availabilities')
category = pgettext_lazy('export_category', 'Product data')
description = gettext_lazy('Download a spreadsheet of all quotas including their current availability.')
def iterate_list(self, form_data):
has_subevents = self.event.has_subevents
@@ -908,21 +889,17 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions')
organizer_required_permission = 'can_manage_gift_cards'
category = pgettext_lazy('export_category', 'Gift cards')
description = gettext_lazy('Download a spreadsheet of all gift card transactions.')
@property
def additional_form_fields(self):
d = [
('date_from',
forms.DateField(
label=_('Start date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
('date_range',
DateFrameField(
label=_('Date range'),
include_future_frames=False,
required=False
)),
]
d = OrderedDict(d)
@@ -933,22 +910,12 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_from'):
date_value = form_data.get('date_from')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone)
)
if form_data.get('date_to'):
date_value = form_data.get('date_to')
if isinstance(date_value, str):
date_value = dateutil.parser.parse(date_value).date()
qs = qs.filter(
datetime__lte=make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone)
)
if form_data.get('date_range'):
dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
if dt_start:
qs = qs.filter(datetime__gte=dt_start)
if dt_end:
qs = qs.filter(datetime__lt=dt_end)
headers = [
_('Gift card code'),
@@ -978,6 +945,8 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
class GiftcardRedemptionListExporter(ListExporter):
identifier = 'giftcardredemptionlist'
verbose_name = gettext_lazy('Gift card redemptions')
category = pgettext_lazy('export_category', 'Order data')
description = gettext_lazy('Download a spreadsheet of all payments or refunds that involve gift cards.')
def iterate_list(self, form_data):
payments = OrderPayment.objects.filter(
@@ -1023,14 +992,18 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards')
organizer_required_permission = 'can_manage_gift_cards'
category = pgettext_lazy('export_category', 'Gift cards')
description = gettext_lazy('Download a spreadsheet of all gift cards including their current value.')
@property
def additional_form_fields(self):
return OrderedDict(
[
('date', forms.DateTimeField(
('date', forms.SplitDateTimeField(
label=_('Show value at'),
initial=now(),
required=False,
widget=SplitDateTimePickerWidget(),
help_text=_('Defaults to the time of report.')
)),
('testmode', forms.ChoiceField(
label=_('Test mode'),
@@ -1058,12 +1031,13 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
)
def iterate_list(self, form_data):
d = form_data.get('date') or now()
s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'),
datetime__lte=form_data['date']
datetime__lte=d
).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = self.organizer.issued_gift_cards.filter(
issuance__lte=form_data['date']
issuance__lte=d
).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related(
@@ -1078,11 +1052,11 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value':
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=form_data['date']))
qs = qs.exclude(cached_value=0).filter(Q(expires__isnull=True) | Q(expires__gte=d))
elif form_data.get('state') == 'expired_value':
qs = qs.exclude(cached_value=0).filter(expires__lt=form_data['date'])
qs = qs.exclude(cached_value=0).filter(expires__lt=d)
elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date'])
qs = qs.filter(expires__lt=d)
headers = [
_('Gift card code'),
+2
View File
@@ -39,6 +39,8 @@ from ..signals import (
class WaitingListExporter(ListExporter):
identifier = 'waitinglist'
verbose_name = _('Waiting list')
category = pgettext_lazy('export_category', 'Waiting list')
description = _('Download a spread sheet with all your waiting list data.')
# map selected status to label and queryset-filter
status_filters = [
+15 -8
View File
@@ -135,6 +135,10 @@ class NamePartsWidget(forms.MultiWidget):
data.append(value.get(fname, ""))
if '_legacy' in value and not data[-1]:
data[-1] = value.get('_legacy', '')
elif not any(d for d in data) and '_scheme' in value:
scheme = PERSON_NAME_SCHEMES[value['_scheme']]
data[-1] = scheme['concatenation'](value).strip()
return data
def render(self, name: str, value, attrs=None, renderer=None) -> str:
@@ -527,7 +531,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
code='aspect_ratio_not_3_by_4',
)
except Exception as exc:
logger.exception('foo')
logger.exception('Could not parse image')
# Pillow doesn't recognize it as an image.
if isinstance(exc, ValidationError):
raise
@@ -571,7 +575,7 @@ class BaseQuestionsForm(forms.Form):
add_fields = {}
if item.admission and event.settings.attendee_names_asked:
if item.ask_attendee_data and event.settings.attendee_names_asked:
add_fields['attendee_name_parts'] = NamePartsFormField(
max_length=255,
required=event.settings.attendee_names_required and not self.all_optional,
@@ -580,7 +584,7 @@ class BaseQuestionsForm(forms.Form):
label=_('Attendee name'),
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
)
if item.admission and event.settings.attendee_emails_asked:
if item.ask_attendee_data and event.settings.attendee_emails_asked:
add_fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required and not self.all_optional,
label=_('Attendee email'),
@@ -591,7 +595,7 @@ class BaseQuestionsForm(forms.Form):
}
)
)
if item.admission and event.settings.attendee_company_asked:
if item.ask_attendee_data and event.settings.attendee_company_asked:
add_fields['company'] = forms.CharField(
required=event.settings.attendee_company_required and not self.all_optional,
label=_('Company'),
@@ -599,7 +603,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.company if cartpos else orderpos.company),
)
if item.admission and event.settings.attendee_addresses_asked:
if item.ask_attendee_data and event.settings.attendee_addresses_asked:
add_fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'),
@@ -915,6 +919,7 @@ class BaseQuestionsForm(forms.Form):
class BaseInvoiceAddressForm(forms.ModelForm):
vat_warning = False
address_validation = False
class Meta:
model = InvoiceAddress
@@ -1050,6 +1055,9 @@ class BaseInvoiceAddressForm(forms.ModelForm):
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
def clean(self):
from pretix.base.addressvalidation import \
validate_address # local import to prevent impact on startup time
data = self.cleaned_data
if not data.get('is_business'):
data['company'] = ''
@@ -1065,9 +1073,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False
if data.get('city') and data.get('country') and str(data['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not data.get('state'):
self.add_error('state', _('This field is required.'))
if self.address_validation:
self.cleaned_data = data = validate_address(data, self.all_optional)
self.instance.name_parts = data.get('name_parts')
+18 -12
View File
@@ -242,6 +242,12 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
buffer.seek(0)
return 'invoice.pdf', 'application/pdf', buffer.read()
def _clean_text(self, text, tags=None):
return bleach.clean(
text,
tags=tags or []
).strip().replace('<br>', '<br />').replace('\n', '<br />\n')
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
identifier = 'classic'
@@ -266,7 +272,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
invoice_to_top = 52 * mm
def _draw_invoice_to(self, canvas):
p = Paragraph(bleach.clean(self.invoice.address_invoice_to, tags=[]).strip().replace('\n', '<br />\n'),
p = Paragraph(self._clean_text(self.invoice.address_invoice_to),
style=self.stylesheet['Normal'])
p.wrapOn(canvas, self.invoice_to_width, self.invoice_to_height)
p_size = p.wrap(self.invoice_to_width, self.invoice_to_height)
@@ -279,7 +285,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
def _draw_invoice_from(self, canvas):
p = Paragraph(
bleach.clean(self.invoice.full_invoice_from, tags=[]).strip().replace('\n', '<br />\n'),
self._clean_text(self.invoice.full_invoice_from),
style=self.stylesheet['InvoiceFrom']
)
p.wrapOn(canvas, self.invoice_from_width, self.invoice_from_height)
@@ -474,8 +480,8 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.custom_field:
story.append(Paragraph(
'{}: {}'.format(
bleach.clean(str(self.invoice.event.settings.invoice_address_custom_field), tags=[]).strip().replace('\n', '<br />\n'),
bleach.clean(self.invoice.custom_field, tags=[]).strip().replace('\n', '<br />\n'),
self._clean_text(str(self.invoice.event.settings.invoice_address_custom_field)),
self._clean_text(self.invoice.custom_field),
),
self.stylesheet['Normal']
))
@@ -483,7 +489,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.internal_reference:
story.append(Paragraph(
pgettext('invoice', 'Customer reference: {reference}').format(
reference=bleach.clean(self.invoice.internal_reference, tags=[]).strip().replace('\n', '<br />\n'),
reference=self._clean_text(self.invoice.internal_reference),
),
self.stylesheet['Normal']
))
@@ -491,20 +497,20 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.invoice_to_vat_id:
story.append(Paragraph(
pgettext('invoice', 'Customer VAT ID') + ': ' +
bleach.clean(self.invoice.invoice_to_vat_id, tags=[]).replace("\n", "<br />\n"),
self._clean_text(self.invoice.invoice_to_vat_id),
self.stylesheet['Normal']
))
if self.invoice.invoice_to_beneficiary:
story.append(Paragraph(
pgettext('invoice', 'Beneficiary') + ':<br />' +
bleach.clean(self.invoice.invoice_to_beneficiary, tags=[]).replace("\n", "<br />\n"),
self._clean_text(self.invoice.invoice_to_beneficiary),
self.stylesheet['Normal']
))
if self.invoice.introductory_text:
story.append(Paragraph(
self.invoice.introductory_text,
self._clean_text(self.invoice.introductory_text, tags=['br']),
self.stylesheet['Normal']
))
story.append(Spacer(1, 10 * mm))
@@ -571,7 +577,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
bleach.clean(description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
@@ -587,7 +593,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
description = description + "\n" + single_price_line
tdata.append((
Paragraph(
bleach.clean(description, tags=['br']).strip().replace('<br>', '<br/>').replace('\n', '<br />\n'),
self._clean_text(description, tags=['br']),
self.stylesheet['Normal']
),
str(len(lines)),
@@ -657,7 +663,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.additional_text:
story.append(Paragraph(
self.invoice.additional_text,
self._clean_text(self.invoice.additional_text, tags=['br']),
self.stylesheet['Normal']
))
story.append(Spacer(1, 5 * mm))
@@ -794,7 +800,7 @@ class Modern1Renderer(ClassicInvoiceRenderer):
if not self.invoice.invoice_from:
return
c = [
bleach.clean(l, tags=[]).strip().replace('\n', '<br />\n')
self._clean_text(l)
for l in self.invoice.address_invoice_from.strip().split('\n')
]
p = Paragraph(' · '.join(c), style=self.stylesheet['Sender'])
+9 -10
View File
@@ -30,7 +30,6 @@ from django.urls import get_script_prefix
from django.utils import timezone, translation
from django.utils.cache import patch_vary_headers
from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import LANGUAGE_SESSION_KEY
from django.utils.translation.trans_real import (
check_for_language, get_supported_language_variant, language_code_re,
parse_accept_lang_header,
@@ -128,12 +127,7 @@ def get_language_from_user_settings(request: HttpRequest) -> str:
return lang_code
def get_language_from_session_or_cookie(request: HttpRequest) -> str:
if hasattr(request, 'session'):
lang_code = request.session.get(LANGUAGE_SESSION_KEY)
if lang_code in _supported and lang_code is not None and check_for_language(lang_code):
return lang_code
def get_language_from_cookie(request: HttpRequest) -> str:
lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
try:
return get_supported_language_variant(lang_code)
@@ -187,14 +181,14 @@ def get_language_from_request(request: HttpRequest) -> str:
return (
get_language_from_user_settings(request)
or get_language_from_customer_settings(request)
or get_language_from_session_or_cookie(request)
or get_language_from_cookie(request)
or get_language_from_browser(request)
or get_language_from_event(request)
or get_default_language()
)
else:
return (
get_language_from_session_or_cookie(request)
get_language_from_cookie(request)
or get_language_from_customer_settings(request)
or get_language_from_user_settings(request)
or get_language_from_browser(request)
@@ -224,6 +218,11 @@ def _merge_csp(a, b):
if k not in a:
a[k] = b[k]
for k, v in a.items():
if "'unsafe-inline'" in v:
# If we need unsafe-inline, drop any hashes or nonce as they will be ignored otherwise
a[k] = [i for i in v if not i.startswith("'nonce-") and not i.startswith("'sha-")]
class SecurityMiddleware(MiddlewareMixin):
CSP_EXEMPT = (
@@ -301,7 +300,7 @@ class SecurityMiddleware(MiddlewareMixin):
resp['Content-Security-Policy'] = _render_csp(h).format(static=staticdomain, dynamic=dynamicdomain,
media=mediadomain)
for k, v in h.items():
h[k] = ' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')
h[k] = sorted(set(' '.join(v).format(static=staticdomain, dynamic=dynamicdomain, media=mediadomain).split(' ')))
resp['Content-Security-Policy'] = _render_csp(h)
elif 'Content-Security-Policy' in resp:
del resp['Content-Security-Policy']
@@ -0,0 +1,29 @@
# Generated by Django 3.2.16 on 2022-12-09 10:06
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0225_orderpayment_process_initiated'),
]
operations = [
migrations.CreateModel(
name='ItemVariationMetaValue',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('value', models.TextField()),
('property', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='variation_values', to='pretixbase.itemmetaproperty')),
('variation', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='meta_values', to='pretixbase.itemvariation')),
],
options={
'unique_together': {('variation', 'property')},
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]
@@ -0,0 +1,27 @@
# Generated by Django 3.2.16 on 2022-12-21 08:59
from django.db import migrations, models
def item_set_personalized(apps, schema_editor):
# We cannot really know if a position was bundled or an add-on, but we can at least guess
Item = apps.get_model("pretixbase", "Item")
Item.objects.filter(admission=True).update(personalized=True)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0226_itemvariationmetavalue'),
]
operations = [
migrations.AddField(
model_name='item',
name='personalized',
field=models.BooleanField(default=False),
),
migrations.RunPython(
item_set_personalized,
migrations.RunPython.noop,
),
]
@@ -0,0 +1,68 @@
# Generated by Django 3.2.16 on 2023-01-18 11:57
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0227_item_personalized'),
]
operations = [
migrations.CreateModel(
name='ScheduledOrganizerExport',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('export_identifier', models.CharField(max_length=190)),
('export_form_data', models.JSONField(default=dict)),
('locale', models.CharField(max_length=250)),
('mail_additional_recipients', models.TextField()),
('mail_additional_recipients_cc', models.TextField()),
('mail_additional_recipients_bcc', models.TextField()),
('mail_subject', models.CharField(max_length=250)),
('mail_template', models.TextField()),
('schedule_rrule', models.TextField(null=True)),
('schedule_rrule_time', models.TimeField()),
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
('error_counter', models.IntegerField(default=0)),
('error_last_message', models.TextField(null=True)),
('timezone', models.CharField(default='UTC', max_length=100)),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.organizer')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='ScheduledEventExport',
fields=[
('id', models.BigAutoField(primary_key=True, serialize=False)),
('export_identifier', models.CharField(max_length=190)),
('export_form_data', models.JSONField(default=dict)),
('locale', models.CharField(max_length=250)),
('mail_additional_recipients', models.TextField()),
('mail_additional_recipients_cc', models.TextField()),
('mail_additional_recipients_bcc', models.TextField()),
('mail_subject', models.CharField(max_length=250)),
('mail_template', models.TextField()),
('schedule_rrule', models.TextField(null=True)),
('schedule_rrule_time', models.TimeField()),
('schedule_next_run', models.DateTimeField(blank=True, null=True)),
('error_counter', models.IntegerField(default=0)),
('error_last_message', models.TextField(null=True)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='scheduled_exports', to='pretixbase.event')),
('owner', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
]
+3 -2
View File
@@ -30,12 +30,13 @@ from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
SubEvent, SubEventMetaValue, generate_invite_token,
)
from .exports import ScheduledEventExport, ScheduledOrganizerExport
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import (
Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaProperty, ItemMetaValue,
ItemVariation, Question, QuestionOption, Quota, SubEventItem,
SubEventItemVariation, itempicture_upload_to,
ItemVariation, ItemVariationMetaValue, Question, QuestionOption, Quota,
SubEventItem, SubEventItemVariation, itempicture_upload_to,
)
from .log import LogEntry
from .memberships import Membership, MembershipType
+53 -3
View File
@@ -35,14 +35,17 @@ from datetime import timedelta
from django.conf import settings
from django.core.exceptions import ValidationError
from django.db import models
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery
from django.db import connection, models
from django.db.models import (
Count, Exists, F, Max, OuterRef, Q, Subquery, Value, Window,
)
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.models import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.helpers import PostgresWindowFrame
class CheckinList(LoggedModel):
@@ -140,7 +143,54 @@ class CheckinList(LoggedModel):
@property
def inside_count(self):
return self.positions_inside.count()
if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
# Use the simple query that works on all databases
return self.positions_inside.count()
# Use the PostgreSQL-specific query using Window functions, which is a lot faster.
# On a real-world example with ~100k tickets, of which ~17k are checked in, we observed
# a speed-up from 29s (old) to a few hundred milliseconds (new)!
# Why is this so much faster? The regular query get's PostgreSQL all busy with filtering
# the tickets both by their belonging the event and checkin status at the same time, while
# this query just iterates over all successful checkins on the list, and -- by the power
# of window functions -- asks "is this an entry that is followed by no exit?". Then we
# dedupliate by position and count it up.
cl = self
base_q, base_params = (
Checkin.all.filter(successful=True, position__in=cl.positions, list=cl)
.annotate(
cnt_exists_after=Window(
expression=Count("position_id", filter=Q(type=Value("exit"))),
partition_by=[F("position_id"), F("list_id")],
order_by=F("datetime").asc(),
frame=PostgresWindowFrame(
"ROWS", start="1 following", end="unbounded following"
),
)
)
.values("position_id", "type", "datetime", "cnt_exists_after")
.query.sql_with_params()
)
with connection.cursor() as cursor:
cursor.execute(
f"""
SELECT COUNT(*) FROM (
SELECT COUNT("position_id")
FROM ({str(base_q)} ) s
WHERE "type" = %s AND "cnt_exists_after" = 0
GROUP BY "position_id"
) a;
""",
[
*base_params,
"entry",
],
)
rows = cursor.fetchall()
if rows:
return rows[0][0]
return 0
@property
@scopes_disabled()
+9
View File
@@ -28,6 +28,7 @@ from typing import Dict, Optional, Tuple
from django.core.exceptions import ValidationError
from django.core.validators import MinValueValidator
from django.db import models
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
@@ -198,6 +199,14 @@ class Discount(LoggedModel):
'subevent_mode': self.subevent_mode,
})
def is_available_by_time(self, now_dt=None) -> bool:
now_dt = now_dt or now()
if self.available_from and self.available_from > now_dt:
return False
if self.available_until and self.available_until < now_dt:
return False
return True
def _apply_min_value(self, positions, idx_group, result):
if self.condition_min_value and sum(positions[idx][2] for idx in idx_group) < self.condition_min_value:
return
+11 -3
View File
@@ -632,6 +632,7 @@ class Event(EventMixin, LoggedModel):
return super().presale_has_ended
def delete_all_orders(self, really=False):
from .checkin import Checkin
from .orders import (
OrderFee, OrderPayment, OrderPosition, OrderRefund, Transaction,
)
@@ -645,6 +646,7 @@ class Event(EventMixin, LoggedModel):
OrderFee.objects.filter(order__event=self).delete()
OrderRefund.objects.filter(order__event=self).delete()
OrderPayment.objects.filter(order__event=self).delete()
Checkin.objects.filter(list__event=self).delete()
self.orders.all().delete()
def save(self, *args, **kwargs):
@@ -728,7 +730,7 @@ class Event(EventMixin, LoggedModel):
from ..signals import event_copy_data
from . import (
Discount, Item, ItemAddOn, ItemBundle, ItemCategory, ItemMetaValue,
Question, Quota,
ItemVariationMetaValue, Question, Quota,
)
# Note: avoid self.set_active_plugins(), it causes trouble e.g. for the badges plugin.
@@ -804,12 +806,18 @@ class Event(EventMixin, LoggedModel):
v.item = i
v.save(force_insert=True)
for imv in ItemMetaValue.objects.filter(item__event=other).prefetch_related('item', 'property'):
for imv in ItemMetaValue.objects.filter(item__event=other):
imv.pk = None
imv.property = item_meta_properties_map[imv.property.pk]
imv.property = item_meta_properties_map[imv.property_id]
imv.item = item_map[imv.item.pk]
imv.save(force_insert=True)
for imv in ItemVariationMetaValue.objects.filter(variation__item__event=other):
imv.pk = None
imv.property = item_meta_properties_map[imv.property_id]
imv.variation = variation_map[imv.variation_id]
imv.save(force_insert=True)
for ia in ItemAddOn.objects.filter(base_item__event=other).prefetch_related('base_item', 'addon_category'):
ia.pk = None
ia.base_item = item_map[ia.base_item.pk]
+139
View File
@@ -0,0 +1,139 @@
#
# 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 datetime import datetime, timedelta
import pytz
from dateutil.rrule import rrulestr
from django.conf import settings
from django.core.serializers.json import DjangoJSONEncoder
from django.db import models
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext_lazy as _
from pretix.base.models import LoggedModel
from pretix.base.validators import RRuleValidator, multimail_validate
class AbstractScheduledExport(LoggedModel):
id = models.BigAutoField(primary_key=True)
export_identifier = models.CharField(
max_length=190,
verbose_name=_("Export"),
)
export_form_data = models.JSONField(
default=dict,
encoder=DjangoJSONEncoder,
)
owner = models.ForeignKey(
"pretixbase.User",
on_delete=models.PROTECT,
)
locale = models.CharField(
verbose_name=_('Language'),
max_length=250
)
mail_additional_recipients = models.TextField(
verbose_name=_('Additional recipients'),
null=False, blank=True, validators=[multimail_validate],
help_text=_("You can specify multiple recipients separated by commas.")
)
mail_additional_recipients_cc = models.TextField(
verbose_name=_('Additional recipients (Cc)'),
null=False, blank=True, validators=[multimail_validate],
help_text=_("You can specify multiple recipients separated by commas.")
)
mail_additional_recipients_bcc = models.TextField(
verbose_name=_('Additional recipients (Bcc)'),
null=False, blank=True, validators=[multimail_validate],
help_text=_("You can specify multiple recipients separated by commas.")
)
mail_subject = models.CharField(
verbose_name=_('Subject'),
max_length=250
)
mail_template = models.TextField(
verbose_name=_('Message'),
)
schedule_rrule = models.TextField(
null=True, blank=True, validators=[RRuleValidator()]
)
schedule_rrule_time = models.TimeField(
verbose_name=_("Requested start time"),
help_text=_("The actual start time might be delayed depending on system load."),
)
schedule_next_run = models.DateTimeField(null=True, blank=True)
error_counter = models.IntegerField(default=0)
error_last_message = models.TextField(null=True, blank=True)
class Meta:
abstract = True
def __str__(self):
return self.mail_subject
def compute_next_run(self):
tz = self.tz
r = rrulestr(self.schedule_rrule)
base_dt = now().astimezone(tz).replace(tzinfo=None)
if now().astimezone(tz).time() < self.schedule_rrule_time:
base_dt -= timedelta(days=1)
new_d = r.after(base_dt, inc=False)
if not new_d:
self.schedule_next_run = None
return
try:
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz)
except pytz.exceptions.AmbiguousTimeError:
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time), tz, is_dst=False)
except pytz.exceptions.NonExistentTimeError:
self.schedule_next_run = make_aware(datetime.combine(new_d.date(), self.schedule_rrule_time) + timedelta(hours=1), tz)
class ScheduledEventExport(AbstractScheduledExport):
event = models.ForeignKey(
"pretixbase.Event", on_delete=models.CASCADE, related_name="scheduled_exports"
)
@property
def tz(self):
return self.event.timezone
class ScheduledOrganizerExport(AbstractScheduledExport):
organizer = models.ForeignKey(
"pretixbase.Organizer", on_delete=models.CASCADE, related_name="scheduled_exports"
)
timezone = models.CharField(max_length=100,
default=settings.TIME_ZONE,
verbose_name=_('Timezone'))
@property
def tz(self):
return pytz.timezone(self.timezone)
+44 -7
View File
@@ -62,6 +62,7 @@ from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice
from ...helpers.images import ImageSizeValidator
from .event import Event, SubEvent
@@ -310,6 +311,8 @@ class Item(LoggedModel):
:type tax_rate: decimal.Decimal
:param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise)
:type admission: bool
:param personalized: ``True``, if attendee information should be collected for this ticket
:type personalized: bool
:param picture: A product picture to be shown next to the product description
:type picture: File
:param available_from: The date this product goes on sale
@@ -396,8 +399,14 @@ class Item(LoggedModel):
admission = models.BooleanField(
verbose_name=_("Is an admission ticket"),
help_text=_(
'Whether or not buying this product allows a person to enter '
'your event'
'Whether or not buying this product allows a person to enter your event'
),
default=False
)
personalized = models.BooleanField(
verbose_name=_("Is a personalized ticket"),
help_text=_(
'Whether or not buying this product allows to enter attendee information'
),
default=False
)
@@ -421,7 +430,8 @@ class Item(LoggedModel):
picture = models.ImageField(
verbose_name=_("Product picture"),
null=True, blank=True, max_length=255,
upload_to=itempicture_upload_to
upload_to=itempicture_upload_to,
validators=[ImageSizeValidator()]
)
available_from = models.DateTimeField(
verbose_name=_("Available from"),
@@ -578,6 +588,10 @@ class Item(LoggedModel):
return self.event.settings.show_quota_left
return self.show_quota_left
@property
def ask_attendee_data(self):
return self.admission and self.personalized
def tax(self, price=None, base_price_is='auto', currency=None, invoice_address=None, override_tax_rate=None, include_bundled=False):
price = price if price is not None else self.default_price
@@ -1008,6 +1022,16 @@ class ItemVariation(models.Model):
return False
return True
@property
def meta_data(self):
data = self.item.meta_data
if hasattr(self, 'meta_values_cached'):
data.update({v.property.name: v.value for v in self.meta_values_cached})
else:
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
class ItemAddOn(models.Model):
"""
@@ -1787,8 +1811,21 @@ class ItemMetaValue(LoggedModel):
class Meta:
unique_together = ('item', 'property')
def delete(self, *args, **kwargs):
super().delete(*args, **kwargs)
def save(self, *args, **kwargs):
super().save(*args, **kwargs)
class ItemVariationMetaValue(LoggedModel):
"""
A meta-data value assigned to an item variation, overriding the value on the item.
:param variation: The variation this metadata is valid for
:type variation: ItemVariation
:param property: The property this value belongs to
:type property: ItemMetaProperty
:param value: The actual value
:type value: str
"""
variation = models.ForeignKey('ItemVariation', on_delete=models.CASCADE, related_name='meta_values')
property = models.ForeignKey('ItemMetaProperty', on_delete=models.CASCADE, related_name='variation_values')
value = models.TextField()
class Meta:
unique_together = ('variation', 'property')
+12 -10
View File
@@ -80,6 +80,7 @@ from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete
from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map
from ._transactions import (
_fail, _transactions_mark_order_clean, _transactions_mark_order_dirty,
)
@@ -807,7 +808,7 @@ class Order(LockModel, LoggedModel):
return True
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions:
if (cp.item.admission and ask_names) or cp.item.questions.all():
if (cp.item.ask_attendee_data and ask_names) or cp.item.questions.all():
return True
return False # nothing there to modify
@@ -996,7 +997,7 @@ class Order(LockModel, LoggedModel):
position and the attendee email will be used if available.
"""
from pretix.base.services.mail import (
SendMailException, TolerantDict, mail, render_mail,
SendMailException, mail, render_mail,
)
if not self.email and not (position and position.attendee_email):
@@ -1012,7 +1013,7 @@ class Order(LockModel, LoggedModel):
try:
email_content = render_mail(template, context)
subject = str(subject).format_map(TolerantDict(context))
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender,
@@ -1651,7 +1652,7 @@ class OrderPayment(models.Model):
}, user=user, auth=auth)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_date=None):
ignore_date=False, lock=True, payment_date=None, generate_invoice=True):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required
@@ -1714,10 +1715,11 @@ class OrderPayment(models.Model):
))
return
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum)
self._mark_order_paid(count_waitinglist, send_mail, force, user, auth, mail_text, ignore_date, lock, payment_sum - refund_sum,
generate_invoice)
def _mark_order_paid(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_refund_sum=0):
ignore_date=False, lock=True, payment_refund_sum=0, allow_generate_invoice=True):
from pretix.base.services.invoices import (
generate_invoice, invoice_qualified,
)
@@ -1734,7 +1736,7 @@ class OrderPayment(models.Model):
ignore_date=ignore_date)
invoice = None
if invoice_qualified(self.order):
if invoice_qualified(self.order) and allow_generate_invoice:
invoices = self.order.invoices.filter(is_cancellation=False).count()
cancellations = self.order.invoices.filter(is_cancellation=True).count()
gen_invoice = (
@@ -2413,7 +2415,7 @@ class OrderPosition(AbstractPosition):
:param attach_ical: Attach relevant ICS files
"""
from pretix.base.services.mail import (
SendMailException, TolerantDict, mail, render_mail,
SendMailException, mail, render_mail,
)
if not self.attendee_email:
@@ -2426,7 +2428,7 @@ class OrderPosition(AbstractPosition):
recipient = self.attendee_email
try:
email_content = render_mail(template, context)
subject = str(subject).format_map(TolerantDict(context))
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
@@ -2686,7 +2688,7 @@ class CartPosition(AbstractPosition):
category_key = (self.item.category.position, self.item.category.id) if self.item.category_id is not None else (0, 0)
item_key = self.item.position, self.item_id
variation_key = (self.variation.position, self.variation.id) if self.variation_id is not None else (0, 0)
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else None), self.pk)
line_key = (self.price, (self.voucher_id or 0), (self.seat.sorting_rank if self.seat_id else 0), self.pk)
sort_key = subevent_key + category_key + item_key + variation_key + line_key
if self.addon_to_id:
+10 -1
View File
@@ -23,6 +23,7 @@ import json
from decimal import Decimal
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models
from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _, pgettext
@@ -149,7 +150,15 @@ class TaxRule(LoggedModel):
rate = models.DecimalField(
max_digits=10,
decimal_places=2,
verbose_name=_("Tax rate")
validators=[
MaxValueValidator(
limit_value=Decimal("100.00"),
),
MinValueValidator(
limit_value=Decimal("0.00"),
),
],
verbose_name=_("Tax rate"),
)
price_includes_tax = models.BooleanField(
verbose_name=_("The configured product prices include the tax amount"),
+72 -10
View File
@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>.
#
from datetime import timedelta
from typing import Any, Dict, Union
from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models, transaction
@@ -27,14 +28,16 @@ from django.db.models import F, Q, Sum
from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager
from i18nfield.strings import LazyI18nString
from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context
from pretix.base.i18n import language
from pretix.base.models import Voucher
from pretix.base.services.mail import mail
from pretix.base.models import User, Voucher
from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.base.settings import PERSON_NAME_SCHEMES
from ...helpers.format import format_map
from .base import LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation
@@ -213,15 +216,74 @@ class WaitingListEntry(LoggedModel):
self.voucher = v
self.save()
self.send_mail(
self.event.settings.mail_subject_waiting_list,
self.event.settings.mail_text_waiting_list,
get_email_context(
event=self.event,
waiting_list_entry=self,
waiting_list_voucher=v,
event_or_subevent=self.subevent or self.event,
),
user=user,
auth=auth,
)
def send_mail(self, subject: Union[str, LazyI18nString], template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.waitinglist.email.sent',
user: User=None, headers: dict=None, sender: str=None, auth=None, auto_email=True,
attach_other_files: list=None, attach_cached_files: list=None):
"""
Sends an email to the entry's contact address.
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, and ``recipient``
parameters.
* Create a ``LogEntry`` with the email contents.
:param subject: Subject of the email
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
:param context: Dictionary to use for rendering the template
:param log_entry_type: Key to be used for the log entry
:param user: Administrative user who triggered this mail to be sent
:param headers: Dictionary with additional mail headers
:param sender: Custom email sender.
"""
if not self.email:
return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
with language(self.locale, self.event.settings.region):
mail(
self.email,
self.event.settings.mail_subject_waiting_list,
self.event.settings.mail_text_waiting_list,
get_email_context(event=self.event, waiting_list_entry=self),
self.event,
locale=self.locale
)
recipient = self.email
try:
email_content = render_mail(template, context)
subject = format_map(subject, context)
mail(
recipient, subject, template, context,
self.event,
self.locale,
headers=headers,
sender=sender,
auto_email=auto_email,
attach_other_files=attach_other_files,
attach_cached_files=attach_cached_files,
)
except SendMailException:
raise
else:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
}
)
@staticmethod
def clean_itemvar(event, item, variation):
+18 -15
View File
@@ -68,6 +68,7 @@ from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.views import get_cart, get_cart_total
@@ -175,7 +176,9 @@ class BasePaymentProvider:
"""
Set this to ``True`` if your ``execute_payment`` function needs to be triggered by a user request, i.e. either
needs the ``request`` object or might require a browser redirect. If this is ``False``, you will not receive
a ``request`` and may not redirect since execute_payment might be called server-side.
a ``request`` and may not redirect since execute_payment might be called server-side. You should ensure that
your ``execute_payment`` method has a limited execution time (i.e. by using ``timeout`` for all external calls)
and handles all error cases appropriately.
"""
return True
@@ -322,16 +325,6 @@ class BasePaymentProvider:
help_text=_('Users will not be able to choose this payment provider after the given date.'),
required=False,
)),
('_invoice_text',
I18nFormField(
label=_('Text on invoices'),
help_text=_('Will be printed just below the payment figures and above the closing text on invoices. '
'This will only be used if the invoice is generated before the order is paid. If the '
'invoice is generated later, it will show a text stating that it has already been paid.'),
required=False,
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_total_min',
forms.DecimalField(
label=_('Minimum order total'),
@@ -379,6 +372,16 @@ class BasePaymentProvider:
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
required=False
)),
('_invoice_text',
I18nFormField(
label=_('Text on invoices'),
help_text=_('Will be printed just below the payment figures and above the closing text on invoices. '
'This will only be used if the invoice is generated before the order is paid. If the '
'invoice is generated later, it will show a text stating that it has already been paid.'),
required=False,
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_restricted_countries',
forms.MultipleChoiceField(
label=_('Restrict to countries'),
@@ -1120,12 +1123,12 @@ class ManualPayment(BasePaymentProvider):
}
def order_pending_mail_render(self, order, payment) -> str:
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order, payment))
msg = format_map(self.settings.get('email_instructions', as_type=LazyI18nString), self.format_map(order, payment))
return msg
def payment_pending_render(self, request, payment) -> str:
return rich_text(
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(payment.order, payment))
format_map(self.settings.get('pending_description', as_type=LazyI18nString), self.format_map(payment.order, payment))
)
@@ -1385,7 +1388,7 @@ class GiftCardPayment(BasePaymentProvider):
except GiftCard.MultipleObjectsReturned:
messages.error(request, _("This gift card can not be redeemed since its code is not unique. Please contact the organizer of this event."))
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
def execute_payment(self, request: HttpRequest, payment: OrderPayment, is_early_special_case=False) -> str:
for p in payment.order.positions.all():
if p.item.issue_giftcard:
raise PaymentException(_("You cannot pay with gift cards when buying a gift card."))
@@ -1421,7 +1424,7 @@ class GiftCardPayment(BasePaymentProvider):
'gift_card': gc.pk,
'transaction_id': trans.pk,
}
payment.confirm()
payment.confirm(send_mail=not is_early_special_case, generate_invoice=not is_early_special_case)
except PaymentException as e:
payment.fail(info={'error': str(e)})
raise e
+29 -2
View File
@@ -35,6 +35,7 @@
import copy
import hashlib
import itertools
import json
import logging
import os
import re
@@ -46,12 +47,15 @@ from collections import OrderedDict
from functools import partial
from io import BytesIO
import jsonschema
from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display
from django.conf import settings
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db.models import Max, Min
from django.dispatch import receiver
from django.utils.deconstruct import deconstructible
from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape
@@ -740,12 +744,14 @@ class Renderer:
if o['content'] == 'other' or o['content'] == 'other_i18n':
if o['content'] == 'other_i18n':
text = str(LazyI18nString(o['text_i18n']))
text = str(LazyI18nString(o.get('text_i18n', {})))
else:
text = o['text']
text = o.get('text', '')
def replace(x):
if x.group(1).startswith('itemmeta:'):
if op.variation_id:
return op.variation.meta_data.get(x.group(1)[9:]) or ''
return op.item.meta_data.get(x.group(1)[9:]) or ''
elif x.group(1).startswith('meta:'):
return ev.meta_data.get(x.group(1)[5:]) or ''
@@ -766,6 +772,8 @@ class Renderer:
return re.sub(r'\{([a-zA-Z0-9:_]+)\}', replace, text)
elif o['content'].startswith('itemmeta:'):
if op.variation_id:
return op.variation.meta_data.get(o['content'][9:]) or ''
return op.item.meta_data.get(o['content'][9:]) or ''
elif o['content'].startswith('meta:'):
@@ -971,3 +979,22 @@ class Renderer:
output.write(outbuffer)
outbuffer.seek(0)
return outbuffer
@deconstructible
class PdfLayoutValidator:
def __call__(self, value):
if not isinstance(value, dict):
try:
val = json.loads(value)
except ValueError:
raise ValidationError(_('Your layout file is not a valid JSON file.'))
else:
val = value
with open(finders.find('schema/pdf-layout.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
e = str(e).replace('%', '%%')
raise ValidationError(_('Your layout file is not a valid layout. Error message: {}').format(e))
+8 -1
View File
@@ -65,7 +65,14 @@ def get_all_plugins(event=None) -> List[type]:
)
class PluginConfig(AppConfig):
class PluginConfigMeta(type):
def __getattribute__(cls, item):
if item == "default" and cls is PluginConfig:
return False
return super().__getattribute__(item)
class PluginConfig(AppConfig, metaclass=PluginConfigMeta):
IGNORE = False
def __init__(self, *args, **kwargs):
+3 -3
View File
@@ -59,10 +59,10 @@ class RelativeDateWrapper:
def date(self, event) -> datetime.date:
from .models import SubEvent
if isinstance(self.data, datetime.date):
return self.data
elif isinstance(self.data, datetime.datetime):
if isinstance(self.data, datetime.datetime):
return self.data.date()
elif isinstance(self.data, datetime.date):
return self.data
else:
if self.data.minutes_before is not None:
raise ValueError('A minute-based relative datetime can not be used as a date')
+5 -4
View File
@@ -35,12 +35,13 @@ from pretix.base.models import (
SubEvent, User, WaitingListEntry,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException, TolerantDict, mail
from pretix.base.services.mail import SendMailException, mail
from pretix.base.services.orders import (
OrderChangeManager, OrderError, _cancel_order, _try_auto_refund,
)
from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app
from pretix.helpers.format import format_map
logger = logging.getLogger(__name__)
@@ -51,7 +52,7 @@ def _send_wle_mail(wle: WaitingListEntry, subject: LazyI18nString, message: Lazy
try:
mail(
wle.email,
str(subject).format_map(TolerantDict(email_context)),
format_map(subject, email_context),
message,
email_context,
wle.event,
@@ -71,7 +72,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
email_context = get_email_context(event_or_subevent=subevent or order.event, refund_amount=refund_amount,
order=order, position_or_address=ia, event=order.event)
real_subject = str(subject).format_map(TolerantDict(email_context))
real_subject = format_map(subject, email_context)
try:
order.send_mail(
real_subject, message, email_context,
@@ -86,7 +87,7 @@ def _send_mail(order: Order, subject: LazyI18nString, message: LazyI18nString, s
continue
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
real_subject = str(subject).format_map(TolerantDict(email_context))
real_subject = format_map(subject, email_context)
email_context = get_email_context(event_or_subevent=p.subevent or order.event,
event=order.event,
refund_amount=refund_amount,
+1 -1
View File
@@ -140,7 +140,7 @@ error_messages = {
'addon_min_count': _('You need to select at least %(min)s add-ons from the category %(cat)s for the '
'product %(base)s.'),
'addon_no_multi': _('You can select every add-ons from the category %(cat)s for the product %(base)s at most once.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another product.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
'seat_required': _('You need to select a specific seat.'),
'seat_invalid': _('Please select a valid seat.'),
+235 -10
View File
@@ -19,31 +19,49 @@
# 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 typing import Any, Dict
import logging
from datetime import timedelta
from typing import Any, Dict, Union
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.core.files.base import ContentFile
from django.utils.timezone import override
from django.db import connection, transaction
from django.dispatch import receiver
from django.utils.timezone import now, override
from django.utils.translation import gettext
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.email import get_email_context
from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import (
CachedFile, Device, Event, Organizer, TeamAPIToken, User, cachedfile_name,
CachedFile, Device, Event, Organizer, ScheduledEventExport, TeamAPIToken,
User, cachedfile_name,
)
from pretix.base.models.exports import ScheduledOrganizerExport
from pretix.base.services.mail import mail
from pretix.base.services.tasks import (
ProfiledEventTask, ProfiledOrganizerUserTask,
EventTask, OrganizerTask, ProfiledEventTask, ProfiledOrganizerUserTask,
)
from pretix.base.signals import (
register_data_exporters, register_multievent_data_exporters,
periodic_task, register_data_exporters, register_multievent_data_exporters,
)
from pretix.celery_app import app
from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__)
class ExportError(LazyLocaleException):
pass
class ExportEmptyError(ExportError):
pass
@app.task(base=ProfiledEventTask, throws=(ExportError,), bind=True)
def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
def set_progress(val):
@@ -56,7 +74,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
file = CachedFile.objects.get(id=fileid)
with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
responses = register_data_exporters.send(event)
for receiver, response in responses:
for recv, response in responses:
if not response:
continue
ex = response(event, event.organizer, set_progress)
@@ -106,16 +124,16 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
timezone = organizer.settings.timezone or settings.TIME_ZONE
region = organizer.settings.region
with language(locale, region), override(timezone):
if form_data.get('events') is not None:
if form_data.get('events') is not None and not form_data.get('all_events'):
if isinstance(form_data['events'][0], str):
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
else:
events = allowed_events.filter(pk__in=form_data.get('events'))
events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer)
else:
events = allowed_events
events = allowed_events.filter(organizer=organizer)
responses = register_multievent_data_exporters.send(organizer)
for receiver, response in responses:
for recv, response in responses:
if not response:
continue
ex = response(events, organizer, set_progress)
@@ -138,3 +156,210 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
f = ContentFile(data)
file.file.save(cachedfile_name(file, file.filename), f)
return file.pk
def _run_scheduled_export(schedule, context: Union[Event, Organizer], exporter, config_url, retry_func, has_permission):
with language(schedule.locale, context.settings.region), override(schedule.tz):
file = CachedFile(web_download=False)
file.date = now()
file.expires = now() + timedelta(hours=24)
file.save()
def _handle_error(msg, soft=False):
context.log_action(
'pretix.event.export.schedule.failed',
data={
'id': schedule.id,
'export_identifier': schedule.export_identifier,
'export_form_data': schedule.export_form_data,
'reason': msg,
'soft': soft,
}
)
if schedule.owner.is_active:
mail(
email=schedule.owner.email,
subject=gettext('Export failed'),
template='pretixbase/email/export_failed.txt',
context={
'configuration_url': config_url,
'reason': msg,
'soft': soft,
},
event=context if isinstance(context, Event) else None,
organizer=context.organizer if isinstance(context, Event) else context,
locale=schedule.locale,
)
if not soft:
schedule.error_counter += 1
schedule.error_last_message = msg
schedule.save(update_fields=['error_counter', 'error_last_message'])
if not has_permission:
_handle_error(gettext('Permission denied.'))
return
try:
if not exporter:
raise ExportError("Export type not found.")
d = exporter.render(schedule.export_form_data)
if d is None:
raise ExportEmptyError(
gettext('Your export did not contain any data.')
)
file.filename, file.type, data = d
filesize = len(data)
if filesize > 20 * 1024 * 1024: # 20 MB
raise ExportError(
gettext('Your exported data exceeded the size limit for scheduled exports.')
)
f = ContentFile(data)
file.file.save(cachedfile_name(file, file.filename), f)
except ExportEmptyError as e:
_handle_error(str(e), soft=True)
except ExportError as e:
_handle_error(str(e), soft=False)
except Exception:
logger.exception("Scheduled export failed.")
try:
retry_func()
except MaxRetriesExceededError:
_handle_error('Internal Error')
else:
schedule.error_counter = 0
schedule.save(update_fields=['error_counter'])
to = [r for r in schedule.mail_additional_recipients.split(",") if r]
cc = [r for r in schedule.mail_additional_recipients_cc.split(",") if r]
bcc = [r for r in schedule.mail_additional_recipients_bcc.split(",") if r]
if to:
# If there is an explicit To, the owner is Cc. Otherwise, the owner is To. Yes, this is
# purely cosmetical and has policital reasons.
cc.append(schedule.owner.email)
else:
to.append(schedule.owner.email)
mail(
email=to,
cc=cc,
bcc=bcc,
subject=schedule.mail_subject,
template=LazyI18nString(schedule.mail_template),
context=get_email_context(event=context) if isinstance(context, Event) else {},
event=context if isinstance(context, Event) else None,
organizer=context.organizer if isinstance(context, Event) else context,
locale=schedule.locale,
attach_cached_files=[file],
)
context.log_action(
'pretix.event.export.schedule.executed',
data={
'id': schedule.id,
'export_identifier': schedule.export_identifier,
'export_form_data': schedule.export_form_data,
'result_file_size': filesize,
'result_file_name': file.file.name,
}
)
@app.task(base=OrganizerTask, bind=True, max_retries=5, default_retry_delay=120)
def scheduled_organizer_export(self, organizer: Organizer, schedule: int) -> None:
schedule = organizer.scheduled_exports.get(pk=schedule)
allowed_events = schedule.owner.get_events_with_permission('can_view_orders')
if schedule.export_form_data.get('events') is not None and not schedule.export_form_data.get('all_events'):
if isinstance(schedule.export_form_data['events'][0], str):
events = allowed_events.filter(slug__in=schedule.export_form_data.get('events'), organizer=organizer)
else:
events = allowed_events.filter(pk__in=schedule.export_form_data.get('events'), organizer=organizer)
else:
events = allowed_events.filter(organizer=organizer)
responses = register_multievent_data_exporters.send(organizer)
exporter = None
for recv, response in responses:
if not response:
continue
ex = response(events, organizer)
if ex.identifier == schedule.export_identifier:
exporter = ex
break
has_permission = schedule.owner.is_active
if isinstance(exporter, OrganizerLevelExportMixin):
if not schedule.owner.has_organizer_permission(organizer, exporter.organizer_required_permission):
has_permission = False
_run_scheduled_export(
schedule,
organizer,
exporter,
build_absolute_uri(
'control:organizer.export',
kwargs={
'organizer': organizer.slug,
}
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
self.retry,
has_permission,
)
@app.task(base=EventTask, bind=True, max_retries=5, default_retry_delay=120)
def scheduled_event_export(self, event: Event, schedule: int) -> None:
schedule = event.scheduled_exports.get(pk=schedule)
responses = register_data_exporters.send(event)
exporter = None
for recv, response in responses:
if not response:
continue
ex = response(event, event.organizer)
if ex.identifier == schedule.export_identifier:
exporter = ex
break
has_permission = schedule.owner.is_active and schedule.owner.has_event_permission(event.organizer, event, 'can_view_orders')
_run_scheduled_export(
schedule,
event,
exporter,
build_absolute_uri(
'control:event.orders.export',
kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}
) + f'?identifier={schedule.export_identifier}&scheduled={schedule.pk}',
self.retry,
has_permission,
)
@receiver(signal=periodic_task)
@scopes_disabled()
@transaction.atomic
def run_scheduled_exports(sender, **kwargs):
qs = ScheduledEventExport.objects.filter(
schedule_next_run__lt=now(),
error_counter__lt=5,
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked).select_related('event')
for s in qs:
scheduled_event_export.apply_async(kwargs={
'event': s.event_id,
'schedule': s.pk,
})
s.compute_next_run()
s.save(update_fields=['schedule_next_run'])
qs = ScheduledOrganizerExport.objects.filter(
schedule_next_run__lt=now(),
error_counter__lt=5,
).select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked).select_related('organizer')
for s in qs:
scheduled_organizer_export.apply_async(kwargs={
'organizer': s.organizer_id,
'schedule': s.pk,
})
s.compute_next_run()
s.save(update_fields=['schedule_next_run'])
+2 -2
View File
@@ -452,7 +452,7 @@ def build_preview_invoice_pdf(event):
if event.tax_rules.exists():
for i, tr in enumerate(event.tax_rules.all()):
for j in range(150):
for j in range(5):
tax = tr.tax(Decimal('100.00'), base_price_is='gross')
InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product {}").format(i + 1),
@@ -460,7 +460,7 @@ def build_preview_invoice_pdf(event):
tax_rate=tax.rate
)
else:
for i in range(150):
for i in range(5):
InvoiceLine.objects.create(
invoice=invoice, description=_("Sample product A"),
gross_value=100, tax_value=0, tax_rate=0
+23 -9
View File
@@ -76,6 +76,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter, global_email_filter
from pretix.celery_app import app
from pretix.helpers.format import format_map
from pretix.helpers.hierarkey import clean_filename
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.presale.ical import get_private_icals
@@ -98,7 +99,8 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
context: Dict[str, Any] = None, event: Event = None, locale: str = None, order: Order = None,
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = None,
customer: Customer = None, invoices: Sequence = None, attach_tickets=False, auto_email=True, user=None,
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None):
attach_ical=False, attach_cached_files: Sequence = None, attach_other_files: list=None,
plain_text_only=False, no_order_links=False, cc: Sequence[str]=None, bcc: Sequence[str]=None):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -109,7 +111,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param template: The filename of a template to be used. It will be rendered with the locale given in the locale
argument and the context given in the next argument. Alternatively, you can pass a LazyI18nString and
``context`` will be used as the argument to a Python ``.format_map()`` call on the template.
``context`` will be used as the argument to a ``pretix.helpers.format.format_map(template, context)`` call on the template.
:param context: The context for rendering the template (see ``template`` parameter)
@@ -148,12 +150,21 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
:param attach_other_files: A list of file paths on our storage to attach.
:param plain_text_only: If set to ``True``, rendering a HTML version will be skipped.
:param no_order_links: If set to ``True``, no link to the order confirmation page will be auto-appended. Currently
only allowed to use together with ``plain_text_only`` since HTML renderers add their own
links.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend.
"""
if email == INVALID_ADDRESS:
return
if no_order_links and not plain_text_only:
raise ValueError('If you set no_order_links, you also need to set plain_text_only.')
headers = headers or {}
if auto_email:
headers['X-Auto-Response-Suppress'] = 'OOF, NRN, AutoReply, RN'
@@ -200,7 +211,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
signature = ""
bcc = []
bcc = list(bcc or [])
settings_holder = event or organizer
@@ -242,7 +253,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
if order and order.testmode:
subject = "[TESTMODE] " + subject
if order and position:
if order and position and not no_order_links:
body_plain += _(
"You are receiving this email because someone placed an order for {event} for you."
).format(event=event.name)
@@ -258,7 +269,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
}
)
)
elif order:
elif order and not no_order_links:
body_plain += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
@@ -278,7 +289,9 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
with override(timezone):
try:
if 'position' in inspect.signature(renderer.render).parameters:
if plain_text_only:
body_html = None
elif 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, raw_subject, order, position)
else:
# Backwards compatibility
@@ -292,6 +305,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email),
cc=cc,
bcc=bcc,
subject=subject,
body=body_plain,
@@ -344,11 +358,11 @@ class CustomEmail(EmailMultiAlternatives):
@app.task(base=TransactionAwareTask, bind=True, acks_late=True)
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int = None, position: int = None, headers: dict = None, bcc: List[str] = None,
event: int = None, position: int = None, headers: dict = None, cc: List[str] = None, bcc: List[str] = None,
invoices: List[int] = None, order: int = None, attach_tickets=False, user=None,
organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
attach_other_files: List[str] = None) -> bool:
email = CustomEmail(subject, body, sender, to=to, bcc=bcc, headers=headers)
email = CustomEmail(subject, body, sender, to=to, cc=cc, bcc=bcc, headers=headers)
if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
html_with_cid, cid_images = replace_images_with_cid_paths(html)
@@ -608,7 +622,7 @@ def render_mail(template, context):
if isinstance(template, LazyI18nString):
body = str(template)
if context:
body = body.format_map(TolerantDict(context))
body = format_map(body, context)
else:
tpl = get_template(template)
body = tpl.render(context)
+66 -31
View File
@@ -74,7 +74,7 @@ from pretix.base.models.orders import (
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.payment import PaymentException
from pretix.base.payment import GiftCardPayment, PaymentException
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.secrets import assign_ticket_secret
from pretix.base.services import tickets
@@ -1029,6 +1029,9 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
locked = True
lockfn = event.lock
warnings = []
any_payment_failed = False
with lockfn() as now_dt:
positions = list(
positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons')
@@ -1042,25 +1045,54 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
order, payment_objs = _create_order(event, email, positions, now_dt, payment_requests,
locale=locale, address=addr, meta_info=meta_info, sales_channel=sales_channel,
shown_total=shown_total, customer=customer)
try:
for p in payment_objs:
if p.provider == 'free':
p.confirm(send_mail=False, lock=not locked, generate_invoice=False)
except Quota.QuotaExceededException:
pass
free_order_flow = (
payment_objs and
any(p['provider'] == 'free' for p in payment_requests) and
order.pending_sum == Decimal('0.00') and
not order.require_approval
)
if free_order_flow:
# We give special treatment to GiftCardPayment here because our invoice renderer expects gift cards to already be
# processed, and because we historically treat gift card orders like free orders with regards to email texts.
# It would be great to give external gift card plugins the same special treatment, but it feels to risky for now, as
# (a) there would be no email at all if the plugin fails in a weird way and (b) we'd be able to run into
# contradictions when a plugin set both execute_payment_needs_user=False as well as requires_invoice_immediately=True
for p in payment_objs:
if isinstance(p.payment_provider, GiftCardPayment):
try:
for p in payment_objs:
if p.provider == 'free':
p.confirm(send_mail=False, lock=not locked)
except Quota.QuotaExceededException:
pass
p.process_initiated = True
p.save(update_fields=['process_initiated'])
p.payment_provider.execute_payment(None, p, is_early_special_case=True)
except PaymentException as e:
warnings.append(str(e))
any_payment_failed = True
except Exception:
logger.exception('Error during payment attempt')
else:
order.refresh_from_db()
pending_sum = order.pending_sum
free_order_flow = (
payment_objs and
(
any(p['provider'] == 'free' for p in payment_requests) or
all(p['provider'] == 'giftcard' for p in payment_requests)
) and
pending_sum == Decimal('0.00') and
not order.require_approval
)
invoice = order.invoices.last() # Might be generated by plugin already
if not invoice and invoice_qualified(order):
if event.settings.get('invoice_generate') == 'True' or (
event.settings.get('invoice_generate') == 'paid' and any(p['pprov'].requires_invoice_immediately for p in payment_requests)):
invoice_required = (
event.settings.get('invoice_generate') == 'True' or (
event.settings.get('invoice_generate') == 'paid' and (
any(p['pprov'].requires_invoice_immediately for p in payment_requests) or
pending_sum <= Decimal('0.00')
)
)
)
if invoice_required:
invoice = generate_invoice(
order,
trigger_pdf=not event.settings.invoice_email_attachment or not order.email
@@ -1100,23 +1132,22 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
_order_placed_email_attendee(event, order, p, email_attendees_template, subject_attendees_template, log_entry,
is_free=free_order_flow)
warnings = []
any_failed = False
for p in payment_objs:
if not p.payment_provider.execute_payment_needs_user:
try:
p.process_initiated = True
p.save(update_fields=['process_initiated'])
resp = p.payment_provider.execute_payment(None, p)
if isinstance(resp, str):
logger.warning('Payment provider returned URL from execute_payment even though execute_payment_needs_user is not set')
except PaymentException as e:
warnings.append(str(e))
any_failed = True
except Exception:
logger.exception('Error during payment attempt')
if not any_payment_failed:
for p in payment_objs:
if not p.payment_provider.execute_payment_needs_user and not p.process_initiated:
try:
p.process_initiated = True
p.save(update_fields=['process_initiated'])
resp = p.payment_provider.execute_payment(None, p)
if isinstance(resp, str):
logger.warning('Payment provider returned URL from execute_payment even though execute_payment_needs_user is not set')
except PaymentException as e:
warnings.append(str(e))
any_payment_failed = True
except Exception:
logger.exception('Error during payment attempt')
if any_failed:
if any_payment_failed:
# Cancel all other payments because their amount might be wrong now.
for p in payment_objs:
if p.state == OrderPayment.PAYMENT_STATE_CREATED:
@@ -1312,6 +1343,7 @@ def notify_user_changed_order(order, user=None, auth=None, invoices=[]):
class OrderChangeManager:
error_messages = {
'order_canceled': _('You cannot change a canceled order.'),
'product_without_variation': _('You need to select a variation of the product.'),
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
'quota_missing': _('There is no quota defined that allows this operation.'),
@@ -1359,6 +1391,9 @@ class OrderChangeManager:
self._invoice_dirty = False
self._invoices = []
if order.status == Order.STATUS_CANCELED:
raise OrderError(self.error_messages['order_canceled'])
def change_item(self, position: OrderPosition, item: Item, variation: Optional[ItemVariation]):
if (not variation and item.has_variations) or (variation and variation.item_id != item.pk):
raise OrderError(self.error_messages['product_without_variation'])
+25
View File
@@ -109,6 +109,31 @@ class EventTask(app.Task):
return ret
class OrganizerTask(app.Task):
def __call__(self, *args, **kwargs):
if 'organizer_id' in kwargs:
organizer_id = kwargs.get('organizer_id')
with scopes_disabled():
organizer = Organizer.objects.get(pk=organizer_id)
del kwargs['organizer_id']
kwargs['organizer'] = organizer
elif 'organizer' in kwargs:
organizer_id = kwargs.get('organizer')
with scopes_disabled():
organizer = Organizer.objects.get(pk=organizer_id)
kwargs['organizer'] = organizer
else:
args = list(args)
organizer_id = args[0]
with scopes_disabled():
organizer = Organizer.objects.get(pk=organizer_id)
args[0] = organizer
with scope(organizer=organizer):
ret = super().__call__(*args, **kwargs)
return ret
class OrganizerUserTask(app.Task):
def __call__(self, *args, **kwargs):
organizer_id = kwargs['organizer']
+5 -5
View File
@@ -206,7 +206,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Ask for attendee names"),
help_text=_("Ask for a name for all tickets which include admission to the event."),
help_text=_("Ask for a name for all personalized tickets."),
)
},
'attendee_names_required': {
@@ -229,10 +229,10 @@ DEFAULTS = {
label=_("Ask for email addresses per ticket"),
help_text=_("Normally, pretix asks for one email address per order and the order confirmation will be sent "
"only to that email address. If you enable this option, the system will additionally ask for "
"individual email addresses for every admission ticket. This might be useful if you want to "
"individual email addresses for every personalized ticket. This might be useful if you want to "
"obtain individual addresses for every attendee even in case of group orders. However, "
"pretix will send the order confirmation by default only to the one primary email address, not to "
"the per-attendee addresses. You can however enable this in the E-mail settings."),
"the per-attendee addresses. You can however enable this in the email settings."),
)
},
'attendee_emails_required': {
@@ -242,7 +242,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField,
'form_kwargs': dict(
label=_("Require email addresses per ticket"),
help_text=_("Require customers to fill in individual e-mail addresses for all admission tickets. See the "
help_text=_("Require customers to fill in individual email addresses for all personalized tickets. See the "
"above option for more details. One email address for the order confirmation will always be "
"required regardless of this setting."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
@@ -2574,7 +2574,7 @@ Your {organizer} team"""))
label=_("Attendee data explanation"),
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}},
help_text=_("This text will be shown above the questions asked for every admission product. You can use it e.g. to explain "
help_text=_("This text will be shown above the questions asked for every personalized product. You can use it e.g. to explain "
"why you need information from them.")
)
},
+4 -2
View File
@@ -218,8 +218,10 @@ class EmailAddressShredder(BaseDataShredder):
o.meta_info = json.dumps(d)
o.save(update_fields=['meta_info', 'email', 'customer'])
for le in self.event.logentry_set.filter(action_type__contains="order.email"):
shred_log_fields(le, banlist=['recipient', 'message', 'subject'])
for le in self.event.logentry_set.filter(
Q(action_type__contains="order.email") | Q(action_type__contains="position.email"),
):
shred_log_fields(le, banlist=['recipient', 'message', 'subject', 'full_mail'])
for le in self.event.logentry_set.filter(action_type="pretix.event.order.contact.changed"):
shred_log_fields(le, banlist=['old_email', 'new_email'])
@@ -0,0 +1,12 @@
{% load i18n %}
{% trans "Your export failed." %}
{% trans "Reason:" %} {{ reason }}
{% if not soft %}
{% trans "If your export fails five times in a row, it will no longer be sent." %}
{% endif %}
{% trans "Configuration link:" %}
{{ configuration_url }}
@@ -0,0 +1,18 @@
{% load i18n %}
{% with widget.subwidgets.0 as widget %}
{% include widget.template_name %}
{% endwith %}
<div class="row" data-display-dependency-value="custom" data-display-dependency="#{{ widget.subwidgets.0.attrs.id }}">
<br>
<div class="col-sm-6">
{% with widget.subwidgets.1 as widget %}
{% include widget.template_name %}
{% endwith %}
</div>
<div class="col-sm-6">
{% with widget.subwidgets.2 as widget %}
{% include widget.template_name %}
{% endwith %}
</div>
</div>
{% spaceless %}{% for widget in widget.subwidgets %}{% endfor %}{% endspaceless %}
+433
View File
@@ -0,0 +1,433 @@
#
# 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 calendar
from datetime import date, datetime, time, timedelta
from itertools import groupby
from typing import Optional, Tuple
import pytz
from django import forms
from django.core.exceptions import ValidationError
from django.utils.formats import date_format
from django.utils.timezone import make_aware, now
from django.utils.translation import gettext_lazy, pgettext_lazy
from rest_framework import serializers
from pretix.helpers.daterange import daterange
def _quarter_start(ref_d):
return ref_d.replace(day=1, month=1 + (ref_d.month - 1) // 3 * 3)
def _week_start(ref_d):
return ref_d - timedelta(ref_d.weekday())
REPORTING_DATE_TIMEFRAMES = (
# (identifier, label, start_inclusive, end_inclusive, includes_future, optgroup, describe)
(
'days_today',
pgettext_lazy('reporting_timeframe', 'Today'),
lambda ref_d: ref_d,
lambda ref_d, start_d: start_d,
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_yesterday',
pgettext_lazy('reporting_timeframe', 'Yesterday'),
lambda ref_d: ref_d - timedelta(days=1),
lambda ref_d, start_d: start_d,
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_last7',
pgettext_lazy('reporting_timeframe', 'Last 7 days'),
lambda ref_d: ref_d - timedelta(days=6),
lambda ref_d, start_d: start_d + timedelta(days=6),
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_last14',
pgettext_lazy('reporting_timeframe', 'Last 14 days'),
lambda ref_d: ref_d - timedelta(days=13),
lambda ref_d, start_d: start_d + timedelta(days=13),
False,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_tomorrow',
pgettext_lazy('reporting_timeframe', 'Tomorrow'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: start_d,
True,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_next7',
pgettext_lazy('reporting_timeframe', 'Next 7 days'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: start_d + timedelta(days=6),
True,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'days_next14',
pgettext_lazy('reporting_timeframe', 'Next 14 days'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: start_d + timedelta(days=13),
True,
pgettext_lazy('reporting_timeframe', 'by day'),
daterange
),
(
'week_this',
pgettext_lazy('reporting_timeframe', 'Current week'),
lambda ref_d: _week_start(ref_d),
lambda ref_d, start_d: start_d + timedelta(days=6),
True,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'week_to_date',
pgettext_lazy('reporting_timeframe', 'Current week to date'),
lambda ref_d: _week_start(ref_d),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'week_previous',
pgettext_lazy('reporting_timeframe', 'Previous week'),
lambda ref_d: _week_start(ref_d) - timedelta(days=7),
lambda ref_d, start_d: start_d + timedelta(days=6),
False,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'week_next',
pgettext_lazy('reporting_timeframe', 'Next week'),
lambda ref_d: _week_start(ref_d + timedelta(days=7)),
lambda ref_d, start_d: start_d + timedelta(days=6),
True,
pgettext_lazy('reporting_timeframe', 'by week'),
lambda start_d, end_d: date_format(start_d, 'WEEK_FORMAT') + ' - ' + daterange(start_d, end_d),
),
(
'month_this',
pgettext_lazy('reporting_timeframe', 'Current month'),
lambda ref_d: ref_d.replace(day=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
True,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'month_to_date',
pgettext_lazy('reporting_timeframe', 'Current month to date'),
lambda ref_d: ref_d.replace(day=1),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'month_previous',
pgettext_lazy('reporting_timeframe', 'Previous month'),
lambda ref_d: (ref_d.replace(day=1) - timedelta(days=1)).replace(day=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
False,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'month_next',
pgettext_lazy('reporting_timeframe', 'Next month'),
lambda ref_d: ref_d.replace(day=calendar.monthrange(ref_d.year, ref_d.month)[1]) + timedelta(days=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month)[1]),
True,
pgettext_lazy('reporting_timeframe', 'by month'),
lambda start_d, end_d: date_format(start_d, 'YEAR_MONTH_FORMAT'),
),
(
'quarter_this',
pgettext_lazy('reporting_timeframe', 'Current quarter'),
lambda ref_d: _quarter_start(ref_d),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
True,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'quarter_to_date',
pgettext_lazy('reporting_timeframe', 'Current quarter to date'),
lambda ref_d: _quarter_start(ref_d),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'quarter_previous',
pgettext_lazy('reporting_timeframe', 'Previous quarter'),
lambda ref_d: _quarter_start(_quarter_start(ref_d) - timedelta(days=1)),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
False,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'quarter_next',
pgettext_lazy('reporting_timeframe', 'Next quarter'),
lambda ref_d: ref_d.replace(
day=calendar.monthrange(ref_d.year, _quarter_start(ref_d).month + 2)[1], month=_quarter_start(ref_d).month + 2
) + timedelta(days=1),
lambda ref_d, start_d: start_d.replace(day=calendar.monthrange(start_d.year, start_d.month + 2)[1], month=start_d.month + 2),
True,
pgettext_lazy('reporting_timeframe', 'by quarter'),
lambda start_d, end_d: f"Q{(start_d.month - 1) // 3 + 1}/{start_d.year}",
),
(
'year_this',
pgettext_lazy('reporting_timeframe', 'Current year'),
lambda ref_d: ref_d.replace(day=1, month=1),
lambda ref_d, start_d: start_d.replace(day=31, month=12),
True,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'year_to_date',
pgettext_lazy('reporting_timeframe', 'Current year to date'),
lambda ref_d: ref_d.replace(day=1, month=1),
lambda ref_d, start_d: ref_d,
False,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'year_previous',
pgettext_lazy('reporting_timeframe', 'Previous year'),
lambda ref_d: (ref_d.replace(day=1, month=1) - timedelta(days=1)).replace(day=1, month=1),
lambda ref_d, start_d: start_d.replace(day=31, month=12),
False,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'year_next',
pgettext_lazy('reporting_timeframe', 'Next year'),
lambda ref_d: ref_d.replace(day=1, month=1, year=ref_d.year + 1),
lambda ref_d, start_d: start_d.replace(day=31, month=12),
True,
pgettext_lazy('reporting_timeframe', 'by year'),
lambda start_d, end_d: str(start_d.year),
),
(
'future',
pgettext_lazy('reporting_timeframe', 'All future (excluding today)'),
lambda ref_d: ref_d + timedelta(days=1),
lambda ref_d, start_d: None,
True,
pgettext_lazy('reporting_timeframe', 'Other'),
lambda start_d, end_d: date_format(start_d, "SHORT_DATE_FORMAT") + ' ',
),
(
'past',
pgettext_lazy('reporting_timeframe', 'All past (including today)'),
lambda ref_d: None,
lambda ref_d, start_d: ref_d,
True, # technically false, but only makes sense to have in a selection that also allows the future, otherwise redundant
pgettext_lazy('reporting_timeframe', 'Other'),
lambda start_d, end_d: ' ' + date_format(end_d, "SHORT_DATE_FORMAT"),
),
)
class DateFrameWidget(forms.MultiWidget):
template_name = 'pretixbase/forms/widgets/dateframe.html'
def __init__(self, *args, **kwargs):
self.timeframe_choices = kwargs.pop('timeframe_choices')
widgets = (
forms.Select(choices=self.timeframe_choices),
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'Start')}),
forms.DateInput(attrs={'class': 'datepickerfield', 'placeholder': pgettext_lazy('timeframe', 'End')}),
)
super().__init__(widgets=widgets, *args, **kwargs)
def decompress(self, value):
if not value:
return ['unset', None, None]
if '/' in value:
return [
'custom',
date.fromisoformat(value.split('/', 1)[0]),
date.fromisoformat(value.split('/', 1)[-1]),
]
return [value, None, None]
def get_context(self, name, value, attrs):
ctx = super().get_context(name, value, attrs)
ctx['required'] = self.timeframe_choices[0][0] == 'unset'
return ctx
def _describe_timeframe(label, start, end, future, describe):
d_start = start(now())
d_end = end(now(), d_start)
details = describe(d_start, d_end)
return f'{label} ({details})'
class DateFrameField(forms.MultiValueField):
default_error_messages = {
**forms.MultiValueField.default_error_messages,
'inconsistent': gettext_lazy('The end date must be after the start date.'),
}
def __init__(self, *args, **kwargs):
include_future_frames = kwargs.pop('include_future_frames')
top_choices = [('custom', gettext_lazy('Custom timeframe'))]
if not kwargs.get('required', True):
top_choices.insert(0, ('unset', pgettext_lazy('reporting_timeframe', 'All time')))
_choices = []
for grouper, group in groupby(REPORTING_DATE_TIMEFRAMES, key=lambda i: i[5]):
options = [
(identifier, _describe_timeframe(label, start, end, future, describe))
for identifier, label, start, end, future, group, describe in group
if include_future_frames or not future
]
if options:
_choices.append((grouper, options))
timeframe_choices = [
('', top_choices)
] + _choices
fields = (
forms.ChoiceField(
choices=timeframe_choices,
required=True
),
forms.DateField(
required=False
),
forms.DateField(
required=False
),
)
if 'widget' not in kwargs:
kwargs['widget'] = DateFrameWidget(timeframe_choices=timeframe_choices)
kwargs.pop('max_length', 0)
kwargs.pop('empty_value', 0)
super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs
)
def compress(self, data_list):
if not data_list:
return None
if data_list[0] == 'unset':
return None
elif data_list[0] == 'custom':
return f'{data_list[1].isoformat() if data_list[1] else ""}/{data_list[2].isoformat() if data_list[2] else ""}'
else:
return data_list[0]
def has_changed(self, initial, data):
if initial is None:
initial = self.widget.decompress(initial)
return super().has_changed(initial, data)
def clean(self, value):
if not value:
return None
if value[0] == 'custom':
if not value[1] and not value[2]:
raise ValidationError(self.error_messages['incomplete'])
if value[1] and value[2] and self.fields[2].to_python(value[2]) < self.fields[1].to_python(value[1]):
raise ValidationError(self.error_messages['inconsistent'])
return super().clean(value)
class SerializerDateFrameField(serializers.CharField):
def to_internal_value(self, data):
if data is None:
return None
try:
resolve_timeframe_to_dates_inclusive(now(), data, pytz.UTC)
except:
raise ValidationError("Invalid date frame")
def to_representation(self, value):
if value is None:
return None
return value
def resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
"""
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of dates
where the first element ist the first possible date value within the timeframe and the second
element is the last possible date value in the timeframe.
Both returned values may be ``None`` for an unlimited interval.
"""
if isinstance(ref_dt, datetime):
ref_dt = ref_dt.astimezone(timezone).date()
if "/" in frame:
start, end = frame.split("/", 1)
return date.fromisoformat(start) if start else None, date.fromisoformat(end) if end else None
for idf, label, start, end, includes_future, *args in REPORTING_DATE_TIMEFRAMES:
if frame == idf:
d_start = start(ref_dt)
d_end = end(ref_dt, d_start)
return d_start, d_end
raise ValueError(f"Invalid timeframe '{frame}'")
def resolve_timeframe_to_datetime_start_inclusive_end_exclusive(ref_dt, frame, timezone) -> Tuple[Optional[date], Optional[date]]:
"""
Given a serialized timeframe, evaluate it relative to `ref_dt` and return a tuple of datetimes
where the first element ist the first possible datetime within the timeframe and the second
element is the first possible datetime value *not* in the timeframe.
Both returned values may be ``None`` for an unlimited interval.
"""
d_start, d_end = resolve_timeframe_to_dates_inclusive(ref_dt, frame, timezone)
dt_start = make_aware(datetime.combine(d_start, time(0, 0, 0)), timezone) if d_start else None
dt_end = make_aware(datetime.combine(d_end + timedelta(days=1), time(0, 0, 0)), timezone) if d_end else None
return dt_start, dt_end
+21 -5
View File
@@ -19,6 +19,12 @@
# 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 dateutil.rrule import rrulestr
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import validate_email
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
# This file is based on an earlier version of pretix which was released under the Apache License 2.0. The full text of
# the Apache License 2.0 can be obtained at <http://www.apache.org/licenses/LICENSE-2.0>.
@@ -32,11 +38,6 @@
# 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.
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.deconstruct import deconstructible
from django.utils.translation import gettext_lazy as _
class BanlistValidator:
@@ -101,3 +102,18 @@ class EmailBanlistValidator(BanlistValidator):
banlist = [
settings.PRETIX_EMAIL_NONE_VALUE,
]
def multimail_validate(val):
s = val.split(',')
for part in s:
validate_email(part.strip())
return s
class RRuleValidator:
def __call__(self, value):
try:
rrulestr(value)
except Exception:
raise ValidationError("Not a valid rrule.")
+1 -1
View File
@@ -78,7 +78,7 @@ class BaseQuestionsViewMixin:
form.pos = cartpos or orderpos
form.show_copy_answers_to_addon_button = form.pos.addon_to and (
set(form.pos.addon_to.item.questions.all()) & set(form.pos.item.questions.all()) or
(form.pos.addon_to.item.admission and form.pos.item.admission and (
(form.pos.addon_to.item.ask_attendee_data and form.pos.item.ask_attendee_data and (
self.request.event.settings.attendee_names_asked or
self.request.event.settings.attendee_emails_asked or
self.request.event.settings.attendee_company_asked or
+1 -1
View File
@@ -28,7 +28,7 @@ from django.urls import reverse
def _is_samesite_referer(request):
referer = request.META.get('HTTP_REFERER')
referer = request.headers.get('referer')
if referer is None:
return False
+5 -3
View File
@@ -28,7 +28,7 @@ from celery import states
from celery.result import AsyncResult
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ValidationError
from django.core.exceptions import PermissionDenied, ValidationError
from django.http import HttpResponse, JsonResponse, QueryDict
from django.shortcuts import redirect, render
from django.test import RequestFactory
@@ -149,6 +149,8 @@ class AsyncMixin:
return redirect(self.get_success_url(value))
def error(self, exception):
if isinstance(exception, PermissionDenied):
raise exception
messages.error(self.request, self.get_error_message(exception))
if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({
@@ -337,8 +339,8 @@ class AsyncPostView(AsyncMixin, View):
depend on the request object unless specifically supported by this class. File upload is currently also
not supported.
"""
known_errortypes = ['ValidationError']
expected_exceptions = (ValidationError,)
known_errortypes = ['ValidationError', 'PermissionDenied']
expected_exceptions = (ValidationError, PermissionDenied)
task_base = ProfiledEventTask
def async_set_progress(self, percentage):
+8 -1
View File
@@ -51,6 +51,9 @@ from django_scopes.forms import SafeModelMultipleChoiceField
from pretix.helpers.hierarkey import clean_filename
from ...base.forms import I18nModelForm
from ...helpers.images import (
IMAGE_EXTS, validate_uploaded_file_for_valid_image,
)
# Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa
@@ -214,12 +217,16 @@ class ExtValidationMixin:
def clean(self, *args, **kwargs):
data = super().clean(*args, **kwargs)
if isinstance(data, File):
if isinstance(data, UploadedFile):
filename = data.name
ext = os.path.splitext(filename)[1]
ext = ext.lower()
if ext not in self.ext_whitelist:
raise forms.ValidationError(_("Filetype not allowed!"))
if ext in IMAGE_EXTS:
validate_uploaded_file_for_valid_image(data)
return data
+7 -10
View File
@@ -34,12 +34,13 @@
# 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.
from decimal import Decimal
from urllib.parse import urlencode, urlparse
from django import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, validate_email
from django.core.validators import MaxValueValidator
from django.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import (
CheckboxSelectMultiple, formset_factory, inlineformset_factory,
@@ -65,6 +66,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
)
from pretix.base.validators import multimail_validate
from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
SplitDateTimePickerWidget,
@@ -135,6 +137,8 @@ class EventWizardBasicsForm(I18nModelForm):
help_text=_("Do you need to pay sales tax on your tickets? In this case, please enter the applicable tax rate "
"here in percent. If you have a more complicated tax situation, you can add more tax rates and "
"detailed configuration later."),
max_value=Decimal("100.00"),
min_value=Decimal("0.00"),
required=False
)
@@ -861,13 +865,6 @@ class InvoiceSettingsForm(SettingsForm):
return data
def multimail_validate(val):
s = val.split(',')
for part in s:
validate_email(part.strip())
return s
def contains_web_channel_validate(val):
if "web" not in val:
raise ValidationError(_("The online shop must be selected to receive these emails."))
@@ -1206,8 +1203,8 @@ class MailSettingsForm(SettingsForm):
'mail_text_resend_link': ['event', 'order'],
'mail_subject_resend_link': ['event', 'order'],
'mail_subject_resend_link_attendee': ['event', 'order'],
'mail_text_waiting_list': ['event', 'waiting_list_entry'],
'mail_subject_waiting_list': ['event', 'waiting_list_entry'],
'mail_text_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
'mail_subject_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
'mail_text_resend_all_links': ['event', 'orders'],
'mail_subject_resend_all_links': ['event', 'orders'],
'mail_attach_ical_description': ['event', 'event_or_subevent'],
+103
View File
@@ -0,0 +1,103 @@
#
# 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 import forms
from django.conf import settings
from django.core.exceptions import ValidationError
from django.utils.translation import gettext_lazy as _
from pytz import common_timezones
from pretix.base.models import ScheduledEventExport
from pretix.base.models.exports import ScheduledOrganizerExport
class ScheduledEventExportForm(forms.ModelForm):
class Meta:
model = ScheduledEventExport
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale']
widgets = {
'mail_additional_recipients': forms.TextInput,
'mail_additional_recipients_cc': forms.TextInput,
'mail_additional_recipients_bcc': forms.TextInput,
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
locale_names = dict(settings.LANGUAGES)
self.fields['locale'] = forms.ChoiceField(
label=_('Language'),
choices=[(a, locale_names[a]) for a in self.instance.event.settings.locales]
)
def clean_mail_additional_recipients(self):
d = self.cleaned_data['mail_additional_recipients'].replace(' ', '')
if len(d.split(',')) > 25:
raise ValidationError(_('Please enter less than 25 recipients.'))
return d
def clean_mail_additional_recipients_cc(self):
d = self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
if len(d.split(',')) > 25:
raise ValidationError(_('Please enter less than 25 recipients.'))
return d
def clean_mail_additional_recipients_bcc(self):
d = self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
if len(d.split(',')) > 25:
raise ValidationError(_('Please enter less than 25 recipients.'))
return d
class ScheduledOrganizerExportForm(forms.ModelForm):
class Meta:
model = ScheduledOrganizerExport
fields = ['mail_additional_recipients', 'mail_additional_recipients_cc', 'mail_additional_recipients_bcc',
'mail_subject', 'mail_template', 'schedule_rrule_time', 'locale', 'timezone']
widgets = {
'mail_additional_recipients': forms.TextInput,
'mail_additional_recipients_cc': forms.TextInput,
'mail_additional_recipients_bcc': forms.TextInput,
'schedule_rrule_time': forms.TimeInput(attrs={'class': 'timepickerfield', 'autocomplete': 'off'}),
}
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
locale_names = dict(settings.LANGUAGES)
self.fields['locale'] = forms.ChoiceField(
label=_('Language'),
choices=[(a, locale_names[a]) for a in self.instance.organizer.settings.locales]
)
self.fields['timezone'] = forms.ChoiceField(
choices=((a, a) for a in common_timezones),
label=_("Timezone"),
)
def clean_mail_additional_recipients(self):
return self.cleaned_data['mail_additional_recipients'].replace(' ', '')
def clean_mail_additional_recipients_cc(self):
return self.cleaned_data['mail_additional_recipients_cc'].replace(' ', '')
def clean_mail_additional_recipients_bcc(self):
return self.cleaned_data['mail_additional_recipients_bcc'].replace(' ', '')
+33 -1
View File
@@ -77,6 +77,31 @@ def get_all_payment_providers():
if PAYMENT_PROVIDERS:
return PAYMENT_PROVIDERS
class FakeSettings:
def __init__(self, orig_settings):
self.orig_settings = orig_settings
def set(self, *args, **kwargs):
pass
def __getattr__(self, item):
return getattr(self.orig_settings, item)
class FakeEvent:
def __init__(self, orig_event):
self.orig_event = orig_event
@property
def settings(self):
return FakeSettings(self.orig_event.settings)
def __getattr__(self, item):
return getattr(self.orig_event, item)
@property
def __class__(self): # hackhack
return Event
with rolledback_transaction():
event = Event.objects.create(
plugins=",".join([app.name for app in apps.get_app_configs()]),
@@ -84,6 +109,7 @@ def get_all_payment_providers():
date_from=now(),
organizer=Organizer.objects.create(name="INTERNAL")
)
event = FakeEvent(event)
provs = register_payment_providers.send(
sender=event
)
@@ -766,6 +792,12 @@ class OrderSearchFilterForm(OrderFilterForm):
)
)
def use_query_hack(self):
return (
self.cleaned_data.get('query') or
self.cleaned_data.get('status') in ('overpaid', 'partially_paid', 'underpaid', 'pendingpaid')
)
def filter_qs(self, qs):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
@@ -2232,7 +2264,7 @@ class DeviceFilterForm(FilterForm):
state = forms.ChoiceField(
label=_('Device status'),
choices=[
('', _('All devices')),
('all', _('All devices')),
('active', _('Active devices')),
('revoked', _('Revoked devices'))
],
+100 -2
View File
@@ -44,6 +44,7 @@ from django.core.files.uploadedfile import UploadedFile
from django.db.models import Max
from django.forms.formsets import DELETION_FIELD_NAME
from django.urls import reverse
from django.utils.functional import cached_property
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import (
@@ -294,6 +295,7 @@ class ItemCreateForm(I18nModelForm):
self.user = kwargs.pop('user')
kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('admission', True)
kwargs['initial'].setdefault('personalized', True)
super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all()
@@ -402,6 +404,8 @@ class ItemCreateForm(I18nModelForm):
self.instance.sales_channels = list(get_all_sales_channels().keys())
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
if not self.instance.admission:
self.instance.personalized = False
instance = super().save(*args, **kwargs)
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
@@ -436,12 +440,21 @@ class ItemCreateForm(I18nModelForm):
v.pk = None
v.item = instance
v.save()
for mv in variation.meta_values.all():
mv.pk = None
mv.variation = v
mv.save(force_insert=True)
else:
ItemVariation.objects.create(
item=instance, value=__('Standard')
)
if self.cleaned_data.get('copy_from'):
for mv in self.cleaned_data['copy_from'].meta_values.all():
mv.pk = None
mv.item = instance
mv.save(force_insert=True)
for question in self.cleaned_data['copy_from'].questions.all():
question.items.add(instance)
question.log_action('pretix.event.question.changed', user=self.user, data={
@@ -484,6 +497,7 @@ class ItemCreateForm(I18nModelForm):
'internal_name',
'category',
'admission',
'personalized',
'default_price',
'tax_rule',
]
@@ -578,13 +592,33 @@ class ItemUpdateForm(I18nModelForm):
'tax_rule',
_("Gift card products should use a tax rule with a rate of 0 percent since sales tax will be applied when the gift card is redeemed.")
)
if d['admission']:
if d.get('admission'):
self.add_error(
'admission',
_(
"Gift card products should not be admission products at the same time."
)
)
if d.get('require_membership') and not d.get('require_membership_types'):
self.add_error(
'require_membership_types',
_(
"If a valid membership is required, at least one valid membership type needs to be selected."
)
)
if not d.get('admission'):
d['personalized'] = False
if d.get('grant_membership_type'):
if not d['grant_membership_type'].transferable and not d['personalized']:
self.add_error(
'personalized' if d['admission'] else 'admission',
_("Your product grants a non-transferable membership and should therefore be a personalized "
"admission ticket. Otherwise customers might not be able to use the membership later. If you "
"want the membership to be non-personalized, set the membership type to be transferable.")
)
return d
def clean_picture(self):
@@ -605,6 +639,7 @@ class ItemUpdateForm(I18nModelForm):
'active',
'sales_channels',
'admission',
'personalized',
'description',
'picture',
'default_price',
@@ -727,6 +762,31 @@ class ItemVariationForm(I18nModelForm):
del self.fields['require_membership']
del self.fields['require_membership_types']
self.meta_fields = []
meta_defaults = {}
if self.instance.pk:
for mv in self.instance.meta_values.all():
meta_defaults[mv.property_id] = mv.value
for p in self.meta_properties:
self.initial[f'meta_{p.name}'] = meta_defaults.get(p.pk)
self.fields[f'meta_{p.name}'] = forms.CharField(
label=p.name,
widget=forms.TextInput(
attrs={
'placeholder': _('Use value from product'),
'data-typeahead-url': reverse('control:event.items.meta.typeahead', kwargs={
'organizer': self.event.organizer.slug,
'event': self.event.slug
}) + '?' + urlencode({
'property': p.name,
}),
},
),
required=False,
)
self.meta_fields.append(f'meta_{p.name}')
class Meta:
model = ItemVariation
localized_fields = '__all__'
@@ -757,6 +817,37 @@ class ItemVariationForm(I18nModelForm):
}),
}
def clean(self):
d = super().clean()
if d.get('require_membership') and not d.get('require_membership_types'):
self.add_error(
'require_membership_types',
_(
"If a valid membership is required, at least one valid membership type needs to be selected."
)
)
return d
def save(self, commit=True):
instance = super().save(commit)
self.meta_fields = []
current_values = {v.property_id: v for v in instance.meta_values.all()}
for p in self.meta_properties:
if self.cleaned_data[f'meta_{p.name}']:
if p.pk in current_values:
current_values[p.pk].value = self.cleaned_data[f'meta_{p.name}']
current_values[p.pk].save()
else:
instance.meta_values.create(property=p, value=self.cleaned_data[f'meta_{p.name}'])
elif p.pk in current_values:
current_values[p.pk].delete()
@property
def meta_properties(self):
if not hasattr(self.event, '_cached_item_meta_properties'):
self.event._cached_item_meta_properties = self.event.item_meta_properties.all()
return self.event._cached_item_meta_properties
class ItemAddOnsFormSet(I18nFormSet):
title = _('Add-ons')
@@ -845,6 +936,7 @@ class ItemBundleFormSet(I18nFormSet):
def _construct_form(self, i, **kwargs):
kwargs['event'] = self.event
kwargs['item'] = self.item
kwargs['item_qs'] = self.item_qs
return super()._construct_form(i, **kwargs)
@property
@@ -856,12 +948,17 @@ class ItemBundleFormSet(I18nFormSet):
empty_permitted=True,
use_required_attribute=False,
locales=self.locales,
item_qs=self.item_qs,
item=self.item,
event=self.event
)
self.add_fields(form, None)
return form
@cached_property
def item_qs(self):
return self.event.items.prefetch_related('variations').all()
def clean(self):
super().clean()
ivs = set()
@@ -889,6 +986,7 @@ class ItemBundleForm(I18nModelForm):
def __init__(self, *args, **kwargs):
self.item = kwargs.pop('item')
self.item_qs = kwargs.pop('item_qs')
super().__init__(*args, **kwargs)
instance = kwargs.get('instance', None)
initial = kwargs.get('initial', {})
@@ -906,7 +1004,7 @@ class ItemBundleForm(I18nModelForm):
super().__init__(*args, **kwargs)
choices = []
for i in self.event.items.prefetch_related('variations').all():
for i in self.item_qs:
pname = str(i)
if not i.is_available():
pname += ' ({})'.format(_('inactive'))
+5 -1
View File
@@ -227,6 +227,10 @@ class ExporterForm(forms.Form):
elif isinstance(v, models.QuerySet):
data[k] = [m.pk for m in v]
if 'all_events' in self.fields and 'events' in self.fields:
if not data.get('all_events') and not data.get('events'):
raise ValidationError(_('Please select some events.'))
return data
@@ -266,7 +270,7 @@ class OtherOperationsForm(forms.Form):
notify = forms.BooleanField(
label=_('Notify user'),
required=False,
initial=True,
initial=False,
help_text=_(
'Send an email to the customer notifying that their order has been changed.'
)
+251
View File
@@ -0,0 +1,251 @@
#
# 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 datetime import timedelta
from dateutil.rrule import DAILY, MONTHLY, WEEKLY, YEARLY, rrule, rrulestr
from django import forms
from django.utils.dates import MONTHS, WEEKDAYS
from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext_lazy as _, pgettext_lazy
class RRuleForm(forms.Form):
# TODO: calendar.setfirstweekday
freq = forms.ChoiceField(
choices=[
('yearly', _('year(s)')),
('monthly', _('month(s)')),
('weekly', _('week(s)')),
('daily', _('day(s)')),
],
initial='weekly'
)
interval = forms.IntegerField(
label=_('Interval'),
initial=1,
min_value=1,
widget=forms.NumberInput(attrs={'min': '1'})
)
dtstart = forms.DateField(
label=_('Start date'),
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
initial=lambda: now().astimezone(get_current_timezone()).date()
)
end = forms.ChoiceField(
choices=[
('count', ''),
('until', ''),
('forever', ''),
],
initial='count',
widget=forms.RadioSelect
)
count = forms.IntegerField(
label=_('Number of repetitions'),
initial=10
)
until = forms.DateField(
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
label=_('Last date'),
required=True,
initial=lambda: now() + timedelta(days=30)
)
yearly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
yearly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
yearly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
yearly_bymonth = forms.ChoiceField(
choices=[
(str(i), MONTHS[i]) for i in range(1, 13)
],
required=False
)
monthly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
monthly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
monthly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
weekly_byweekday = forms.MultipleChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
],
required=False,
widget=forms.CheckboxSelectMultiple
)
def parse_weekdays(self, value):
m = {
'MO': 0,
'TU': 1,
'WE': 2,
'TH': 3,
'FR': 4,
'SA': 5,
'SU': 6
}
if ',' in value:
return [m.get(a) for a in value.split(',')]
else:
return m.get(value)
def to_rrule(self):
rule_kwargs = {}
rule_kwargs['dtstart'] = self.cleaned_data['dtstart']
rule_kwargs['interval'] = self.cleaned_data['interval']
if self.cleaned_data['freq'] == 'yearly':
freq = YEARLY
if self.cleaned_data['yearly_same'] == "off":
rule_kwargs['bysetpos'] = int(self.cleaned_data['yearly_bysetpos'])
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['yearly_byweekday'])
rule_kwargs['bymonth'] = int(self.cleaned_data['yearly_bymonth'])
elif self.cleaned_data['freq'] == 'monthly':
freq = MONTHLY
if self.cleaned_data['monthly_same'] == "off":
rule_kwargs['bysetpos'] = int(self.cleaned_data['monthly_bysetpos'])
rule_kwargs['byweekday'] = self.parse_weekdays(self.cleaned_data['monthly_byweekday'])
elif self.cleaned_data['freq'] == 'weekly':
freq = WEEKLY
if self.cleaned_data['weekly_byweekday']:
rule_kwargs['byweekday'] = [self.parse_weekdays(a) for a in self.cleaned_data['weekly_byweekday']]
elif self.cleaned_data['freq'] == 'daily':
freq = DAILY
if self.cleaned_data['end'] == 'count':
rule_kwargs['count'] = self.cleaned_data['count']
elif self.cleaned_data['end'] == 'until':
rule_kwargs['until'] = self.cleaned_data['until']
return rrule(freq, **rule_kwargs)
@staticmethod
def initial_from_rrule(rule: rrule):
initial = {}
if isinstance(rule, str):
rule = rrulestr(rule)
_rule = rule._original_rule
initial['dtstart'] = rule._dtstart
initial['interval'] = rule._interval
if rule._freq == YEARLY:
initial['freq'] = 'yearly'
initial['yearly_bysetpos'] = _rule.get('bysetpos')
initial['yearly_byweekday'] = _rule.get('byweekday')
initial['yearly_bymonth'] = _rule.get('bymonth')
elif rule._freq == MONTHLY:
initial['freq'] = 'monthly'
initial['monthly_bysetpos'] = _rule.get('bysetpos')
initial['monthly_byweekday'] = _rule.get('byweekday')
elif rule._freq == WEEKLY:
initial['freq'] = 'weekly'
initial['weekly_byweekday'] = _rule.get('byweekday')
elif rule._freq == DAILY:
initial['freq'] = 'daily'
if rule._count:
initial['end'] = 'count'
initial['count'] = rule._count
elif rule._until:
initial['end'] = 'until'
initial['until'] = rule._until
else:
initial['end'] = 'forever'
return initial
+5 -157
View File
@@ -19,17 +19,15 @@
# 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 datetime import datetime, timedelta
from datetime import datetime
from urllib.parse import urlencode
from django import forms
from django.forms import formset_factory
from django.forms.utils import ErrorDict
from django.urls import reverse
from django.utils.dates import MONTHS, WEEKDAYS
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.utils.translation import gettext_lazy as _
from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm
@@ -39,6 +37,7 @@ from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.reldate import RelativeDateTimeField, RelativeDateWrapper
from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.rrule import RRuleForm
from pretix.helpers.money import change_decimal_field
@@ -440,166 +439,15 @@ class CheckinListFormSet(I18nInlineFormSet):
return form
class RRuleForm(forms.Form):
# TODO: calendar.setfirstweekday
class RRuleFormSetForm(RRuleForm):
exclude = forms.BooleanField(
label=_('Exclude these dates instead of adding them.'),
required=False
)
freq = forms.ChoiceField(
choices=[
('yearly', _('year(s)')),
('monthly', _('month(s)')),
('weekly', _('week(s)')),
('daily', _('day(s)')),
],
initial='weekly'
)
interval = forms.IntegerField(
label=_('Interval'),
initial=1,
min_value=1,
widget=forms.NumberInput(attrs={'min': '1'})
)
dtstart = forms.DateField(
label=_('Start date'),
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
initial=lambda: now().date()
)
end = forms.ChoiceField(
choices=[
('count', ''),
('until', ''),
],
initial='count',
widget=forms.RadioSelect
)
count = forms.IntegerField(
label=_('Number of repetitions'),
initial=10
)
until = forms.DateField(
widget=forms.DateInput(
attrs={
'class': 'datepickerfield',
'required': 'required'
}
),
label=_('Last date'),
required=True,
initial=lambda: now() + timedelta(days=30)
)
yearly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
yearly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
yearly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
yearly_bymonth = forms.ChoiceField(
choices=[
(str(i), MONTHS[i]) for i in range(1, 13)
],
required=False
)
monthly_same = forms.ChoiceField(
choices=[
('on', ''),
('off', ''),
],
initial='on',
widget=forms.RadioSelect
)
monthly_bysetpos = forms.ChoiceField(
choices=[
('1', pgettext_lazy('rrule', 'first')),
('2', pgettext_lazy('rrule', 'second')),
('3', pgettext_lazy('rrule', 'third')),
('-1', pgettext_lazy('rrule', 'last')),
],
required=False
)
monthly_byweekday = forms.ChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
('MO,TU,WE,TH,FR,SA,SU', _('Day')),
('MO,TU,WE,TH,FR', _('Weekday')),
('SA,SU', _('Weekend day')),
],
required=False
)
weekly_byweekday = forms.MultipleChoiceField(
choices=[
('MO', WEEKDAYS[0]),
('TU', WEEKDAYS[1]),
('WE', WEEKDAYS[2]),
('TH', WEEKDAYS[3]),
('FR', WEEKDAYS[4]),
('SA', WEEKDAYS[5]),
('SU', WEEKDAYS[6]),
],
required=False,
widget=forms.CheckboxSelectMultiple
)
def parse_weekdays(self, value):
m = {
'MO': 0,
'TU': 1,
'WE': 2,
'TH': 3,
'FR': 4,
'SA': 5,
'SU': 6
}
if ',' in value:
return [m.get(a) for a in value.split(',')]
else:
return m.get(value)
RRuleFormSet = formset_factory(
RRuleForm,
RRuleFormSetForm,
can_order=False, can_delete=True, extra=1
)
+10
View File
@@ -315,6 +315,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.organizer.changed': _('The organizer has been changed.'),
'pretix.organizer.settings': _('The organizer settings have been changed.'),
'pretix.organizer.footerlinks.changed': _('The footer links have been changed.'),
'pretix.organizer.export.schedule.added': _('A scheduled export has been added.'),
'pretix.organizer.export.schedule.changed': _('A scheduled export has been changed.'),
'pretix.organizer.export.schedule.deleted': _('A scheduled export has been deleted.'),
'pretix.organizer.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.organizer.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.giftcards.acceptance.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.webhook.created': _('The webhook has been created.'),
@@ -409,6 +414,11 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.order.refund.done': _('Refund {local_id} has been completed.'),
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'),
'pretix.event.export.schedule.added': _('A scheduled export has been added.'),
'pretix.event.export.schedule.changed': _('A scheduled export has been changed.'),
'pretix.event.export.schedule.deleted': _('A scheduled export has been deleted.'),
'pretix.event.export.schedule.executed': _('A scheduled export has been executed.'),
'pretix.event.export.schedule.failed': _('A scheduled export has failed: {reason}.'),
'pretix.control.auth.user.created': _('The user has been created.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),
@@ -40,6 +40,7 @@
<script type="text/javascript" src="{% static "pretixcontrol/js/sb-admin-2.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/rrule.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/subevent.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
@@ -82,8 +82,10 @@
<thead>
<tr>
<th>
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
<label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
{% endif %}
</th>
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a>
<a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
@@ -125,7 +127,7 @@
{% for e in entries %}
<tr>
<td>
{% if "can_change_orders" in request.eventpermset %}
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
<input type="checkbox" name="checkin" id="id_checkin" class="" value="{{ e.pk }}"/>
{% endif %}
</td>
@@ -198,7 +200,7 @@
</tbody>
</table>
</div>
{% if "can_change_orders" in request.eventpermset %}
{% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
<button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-sign-in" aria-hidden="true"></span>
{% trans "Check-In selected attendees" %}
@@ -207,6 +209,8 @@
<span class="fa fa-sign-out" aria-hidden="true"></span>
{% trans "Check-Out selected attendees" %}
</button>
{% endif %}
{% if "can_change_orders" in request.eventpermset %}
<button type="submit" class="btn btn-danger btn-save" name="revert" value="true">
<span class="fa fa-trash" aria-hidden="true"></span>
{% trans "Delete all check-ins of selected attendees" %}
@@ -36,12 +36,16 @@
</div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group">
{% if request.event %}
{% for l in request.event.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% for l, n in settings.LANGUAGES %}
{% if l in request.event.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endif %}
{% endfor %}
{% else %}
{% for l in request.organizer.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% for l, n in settings.LANGUAGES %}
{% if l in request.organizer.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endif %}
{% endfor %}
{% endif %}
</div>
@@ -90,7 +90,7 @@
</div>
</div>
<h4>{% trans "Attendee data (once per admission ticket)" %}</h4>
<h4>{% trans "Attendee data (once per personalized ticket)" %}</h4>
{% bootstrap_field sform.attendee_names_asked_required layout="control" %}
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %}
@@ -20,13 +20,19 @@
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
<span class="fa fa-user"></span>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
<span class="fa fa-fw fa-user"></span>
<strong>{% trans "Admission product" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
Every purchase of this product represents one person who is allowed to enter your event.
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
By default, we will only offer ticket downloads for these products.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
Only purchases of such products will be considered "attendees" for most statistical
purposes or within some plugins.
{% endblocktrans %}
</div>
<div class="help-block">
@@ -40,12 +46,12 @@
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.admission.html_name }}" {% if not form.admission.value %}checked{% endif %}>
<span class="fa fa-cube"></span>
<span class="fa fa-fw fa-cube"></span>
<strong>{% trans "Non-admission product" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
ticket downloads.
A product that does not represent a person. By default, we will not offer ticket downloads
(but you can still enable ticket downloads in event settings or product settings).
{% endblocktrans %}
</div>
<div class="help-block">
@@ -58,6 +64,47 @@
</div>
</div>
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-circle-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.category layout="control" %}
<div class="form-group">
@@ -1,6 +1,7 @@
{% load i18n %}
{% load bootstrap3 %}
{% load formset_tags %}
{% load getitem %}
<div class="formset" data-formset data-formset-prefix="{{ formset.prefix }}" id="item_variations">
{{ formset.management_form }}
{% bootstrap_formset_errors formset %}
@@ -29,16 +30,20 @@
{% endif %}
</div>
<div class="col-md-2 col-xs-6">
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
</div>
<div class="col-md-2 col-xs-6">
{% for k, c in sales_channels.items %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% endfor %}
</div>
<div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -69,6 +74,27 @@
{% bootstrap_field form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field form.description layout="control" %}
{% if form.meta_fields %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<div class="col-md-9">
{% for fname in form.meta_fields %}
{% with form|getitem:fname as field %}
<div class="row">
<div class="col-md-4">
<label for="{{ field.id_for_label }}">
{{ field.label }}
</label>
</div>
<div class="col-md-8">
{% bootstrap_field field layout="inline" %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% bootstrap_field form.available_from layout="control" %}
{% bootstrap_field form.available_until layout="control" %}
{% bootstrap_field form.sales_channels layout="control" %}
@@ -110,16 +136,20 @@
{% endif %}
</div>
<div class="col-md-2 col-xs-6">
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden" data-toggle="tooltip" title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden" data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
<span class="fa fa-clock-o fa-fw text-muted variation-timeframe variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only available in a limited timeframe" %}"></span>
<span class="fa fa-tags fa-fw text-muted variation-voucher variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Only visible with a voucher" %}"></span>
<span class="fa fa-id-badge fa-fw text-muted variation-membership variation-icon-hidden"
data-toggle="tooltip"
title="{% trans "Require a valid membership" %}"></span>
</div>
<div class="col-md-2 col-xs-6">
{% for k, c in sales_channels.items %}
<span class="fa fa-fw fa-{{ c.icon }} text-muted variation-channel-{{ k }} variation-icon-hidden"
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
data-toggle="tooltip" title="{% trans c.verbose_name %}"></span>
{% endfor %}
</div>
<div class="col-md-1 col-xs-6 text-right flip variation-price">
@@ -141,6 +171,27 @@
{% bootstrap_field formset.empty_form.default_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.original_price addon_after=request.event.currency layout="control" %}
{% bootstrap_field formset.empty_form.description layout="control" %}
{% if formset.empty_form.meta_fields %}
<div class="form-group metadata-group">
<label class="col-md-3 control-label">{% trans "Meta data" %}</label>
<div class="col-md-9">
{% for fname in formset.empty_form.meta_fields %}
{% with formset.empty_form|getitem:fname as field %}
<div class="row">
<div class="col-md-4">
<label for="{{ field.id_for_label }}">
{{ field.label }}
</label>
</div>
<div class="col-md-8">
{% bootstrap_field field layout="inline" %}
</div>
</div>
{% endwith %}
{% endfor %}
</div>
</div>
{% endif %}
{% bootstrap_field formset.empty_form.available_from layout="control" %}
{% bootstrap_field formset.empty_form.available_until layout="control" %}
{% bootstrap_field formset.empty_form.sales_channels layout="control" %}
@@ -27,13 +27,19 @@
{% endfor %}
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %}>
<input type="radio" value="on" name="{{ form.admission.html_name }}" {% if form.admission.value %}checked{% endif %} id="admission_on">
<span class="fa fa-fw fa-user"></span>
<strong>{% trans "Admission product" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
Every purchase of this product represents one person who is allowed to enter your event.
By default, pretix will only ask for attendee information and offer ticket downloads for these products.
By default, we will only offer ticket downloads for these products.
{% endblocktrans %}
</div>
<div class="help-block">
{% blocktrans trimmed %}
Only purchases of such products will be considered "attendees" for most statistical
purposes or within some plugins.
{% endblocktrans %}
</div>
<div class="help-block">
@@ -51,8 +57,8 @@
<strong>{% trans "Non-admission product" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
A product that does not represent a person. By default, pretix will not ask for attendee information or offer
ticket downloads.
A product that does not represent a person. By default, we will not offer ticket downloads
(but you can still enable ticket downloads in event settings or product settings).
{% endblocktrans %}
</div>
<div class="help-block">
@@ -65,6 +71,52 @@
</div>
</div>
<div class="form-group" data-display-dependency="#admission_on">
<label class="col-md-3 control-label">{% trans "Personalization" %}</label>
<div class="col-md-9">
{% for e in form.errors.personalized %}
<div class="alert alert-danger has-error">
{{ e }}
</div>
{% endfor %}
<div class="big-radio radio">
<label>
<input type="radio" value="on" name="{{ form.personalized.html_name }}" {% if form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-id-card-o"></span>
<strong>{% trans "Personalized ticket" %}</strong><br>
<div class="help-block">
{% blocktrans trimmed %}
When this ticket is purchased, the system will ask for a name or other details according
to your event settings.
{% endblocktrans %}
{% if not request.event.settings.attendee_names_asked and not request.event.settings.attendee_emails_asked and not request.event.settings.attendee_company_asked and not request.event.settings.attendee_addresses_asked %}
<br>
<span class="text-warning">
<span class="fa fa-warning" aria-hidden="true"></span>
{% trans "This will currently have no effect since all data fields are turned off in event settings." %}
</span>
<a href="{% url "control:event.settings" organizer=request.event.organizer.slug event=request.event.slug %}#tab-0-2-open"
class="btn btn-default btn-xs" target="_blank">{% trans "Change settings" %}</a>
{% endif %}
</div>
</label>
</div>
<div class="big-radio radio">
<label>
<input type="radio" value="" name="{{ form.personalized.html_name }}" {% if not form.personalized.value %}checked{% endif %}>
<span class="fa fa-fw fa-file-text-o"></span>
<strong>{% trans "Non-personalized ticket" %}</strong>
<div class="help-block">
{% blocktrans trimmed %}
The system will not ask for a name or other attendee details. This only affects
system-provided fields, you can still add your own questions.
{% endblocktrans %}
</div>
</label>
</div>
</div>
</div>
{% bootstrap_field form.description layout="control" %}
{% bootstrap_field form.picture layout="control" %}
{% bootstrap_field form.require_approval layout="control" %}
@@ -81,7 +81,11 @@
</td>
<td>
{% if i.admission %}
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket" %}"></span>
{% if i.personalized %}
<span class="fa fa-id-card-o fa-fw text-muted" data-toggle="tooltip" title="{% trans "Personalized admission ticket" %}"></span>
{% else %}
<span class="fa fa-user fa-fw text-muted" data-toggle="tooltip" title="{% trans "Admission ticket without personalization" %}"></span>
{% endif %}
{% elif i.issue_giftcard %}
<span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span>
{% endif %}
@@ -76,7 +76,7 @@
{% endfor %}
</ul>
{% else %}
<small>{% trans "All admission products" %}</small>
<small>{% trans "All personalized products" %}</small>
{% endif %}
</td>
<td class="dnd-container">
@@ -184,9 +184,11 @@
<dt>{% trans "Order locale" %}</dt>
<dd>
{{ display_locale }}
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% if "can_change_orders" in request.eventpermset %}
<a href="{% url "control:event.order.locale" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% endif %}
</dd>
{% if order.status == "n" %}
<dt>{% trans "Expiry date" %}</dt>
@@ -206,9 +208,11 @@
{{ order.customer.identifier }} {{ order.customer.email }}
</a>
{% endif %}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% if "can_change_orders" in request.eventpermset %}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% endif %}
</dd>
{% endif %}
<dt>{% trans "Contact email" %}</dt>
@@ -217,21 +221,23 @@
{% if order.email and order.email_known_to_work %}
<span class="fa fa-check-circle text-success" data-toggle="tooltip" title="{% trans "We know that this email address works because the user clicked a link we sent them." %}"></span>
{% endif %}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% if order.email %}
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
{% if "can_change_orders" in request.eventpermset %}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% if order.status != "c" %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% csrf_token %}
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% if order.email %}
<a href="{% url "control:event.order.sendmail" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-envelope-o"></span>
</a>
{% if order.status != "c" %}
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
{% csrf_token %}
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% endif %}
{% endif %}
</dd>
@@ -239,9 +245,11 @@
<dt>{% trans "Phone number" %}</dt>
<dd>
{{ order.phone|default_if_none:""|phone_format }}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% if "can_change_orders" in request.eventpermset %}
<a href="{% url "control:event.order.contact" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default btn-xs">
<span class="fa fa-edit"></span>
</a>
{% endif %}
</dd>
{% endif %}
{% if invoices %}
@@ -306,7 +314,7 @@
{% trans "Email invoices" %}
</a>
{% endif %}
{% if can_generate_invoice %}
{% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<br/>
<form class="form-inline helper-display-inline" method="post"
action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
@@ -317,7 +325,7 @@
</form>
{% endif %}
</dd>
{% elif can_generate_invoice %}
{% elif can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<dt>{% trans "Invoices" %}</dt>
<dd>
<form class="form-inline helper-display-inline" method="post"
@@ -335,11 +343,11 @@
<div class="panel panel-default items">
<div class="panel-heading">
<div class="pull-right flip">
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change answers" %}
</a>
{% if 'can_change_orders' in request.eventpermset %}
<a href="{% url "control:event.order.info" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change answers" %}
</a>
&middot; <a href="{% url "control:event.order.change" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
<span class="fa fa-edit"></span>
{% trans "Change products" %}
@@ -460,12 +468,12 @@
{% endif %}
{% if line.has_questions %}
<dl>
{% if line.item.admission and event.settings.attendee_names_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_names_asked %}
<dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_emails_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_emails_asked %}
<dt>{% trans "Attendee email" %}</dt>
<dd>
{% if line.attendee_email %}
@@ -488,7 +496,7 @@
{% endif %}
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_company_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt>
{% trans "Attendee company" %}
</dt>
@@ -496,7 +504,7 @@
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd>
{% endif %}
{% if line.item.admission and event.settings.attendee_addresses_asked %}
{% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt>
{% trans "Attendee address" %}
</dt>
@@ -754,7 +762,7 @@
{% endfor %}
</tbody>
</table>
{% if order.payment_refund_sum > 0 %}
{% if order.payment_refund_sum > 0 and "can_change_orders" in request.eventpermset %}
<a href="{% url "control:event.order.refunds.start" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}" class="btn btn-default">
{% trans "Create a refund" %}
</a>
@@ -942,9 +950,11 @@
{% bootstrap_field comment_form.comment show_help=True show_label=False %}
{% bootstrap_field comment_form.custom_followup_at %}
{% bootstrap_field comment_form.checkin_attention show_help=True show_label=False %}
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
{% if "can_change_orders" in request.eventpermset %}
<button class="btn btn-default">
{% trans "Update comment" %}
</button>
{% endif %}
</form>
</div>
</div>
@@ -4,35 +4,113 @@
{% load order_overview %}
{% block title %}{% trans "Data export" %}{% endblock %}
{% block content %}
<h1>
<h1>
{% trans "Data export" %}
{% if "identifier" in request.GET %}
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
{% endif %}
</h1>
{% for e in exporters %}
<details class="panel panel-default"
{% if request.GET.identifier == e.identifier or request.POST.exporter == e.identifier %}open{% endif %}>
<summary class="panel-heading">
<h3 class="panel-title">
{{ e.verbose_name }}
<i class="fa fa-angle-down collapse-indicator"></i>
</h3>
</summary>
<div id="{{ e.identifier }}">
<div class="panel-body">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
{% bootstrap_form e.form layout='control' %}
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start export" %}
</button>
</form>
</div>
</div>
</details>
{% if scheduled %}
<h2>{% trans "Scheduled exports" %}</h2>
<ul class="list-group">
{% for s in scheduled %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-5 col-md-4 col-xs-12">
<span class="fa fa-fw fa-folder"></span>
{{ s.export_verbose_name }}
<br>
<span class="text-muted">
<span class="fa fa-fw fa-user"></span>
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-5 col-md-6 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "No next run scheduled" %}
{% endif %}
{% if s.export_verbose_name == "?" %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Exporter not found" %}
</strong>
{% elif s.error_counter >= 5 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Disabled due to multiple failures" %}
</strong>
{% elif s.error_counter > 0 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Failed recently" %}
</strong>
{% endif %}
<span class="text-muted">
<br>
<span class="fa fa-fw fa-envelope-o"></span>
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
{% if s.export_verbose_name != "?" %}
<button type="submit" class="btn btn-default" title="{% trans "Run export now" %}" data-toggle="tooltip">
<span class="fa fa-download"></span>
</button>
<button formaction="{% url "control:event.orders.export.scheduled.run" organizer=request.organizer.slug event=request.event.slug pk=s.pk %}"
type="submit"
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
<span class="fa fa-play" aria-hidden="true"></span>
</button>
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
{% endif %}
<a href="{% url "control:event.orders.export.scheduled.delete" event=request.event.slug organizer=request.event.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
</a>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
{% if is_paginated %}
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endif %}
{% regroup exporters by category as category_list %}
{% for c, c_ex in category_list %}
{% if c %}
<h2>{{ c }}</h2>
{% else %}
<h2>{% trans "Other exports" %}</h2>
{% endif %}
<div class="list-group large-link-group">
{% for e in c_ex %}
<a class="list-group-item" href="?identifier={{ e.identifier }}">
<h4>
{{ e.verbose_name }}
{% if e.featured %}
<span class="fa fa-star text-success" data-toggle="tooltip"
title="{% trans "Recommended for new users" %}" aria-hidden="true"></span>
{% endif %}
</h4>
{% if e.description %}
<p>
{{ e.description }}
</p>
{% endif %}
</a>
{% endfor %}
</div>
{% endfor %}
{% endblock %}
@@ -0,0 +1,19 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Delete scheduled export" %}{% endblock %}
{% block content %}
<h1>{% trans "Delete scheduled export" %}</h1>
<form action="" method="post" class="form-horizontal">
{% csrf_token %}
<p>{% blocktrans %}Are you sure you want to delete the scheduled export <strong>{{ export }}</strong>?{% endblocktrans %}</p>
<div class="form-group submit-group">
<a href="{% url "control:event.orders.export" organizer=request.event.organizer.slug event=request.event.slug %}" class="btn btn-default btn-cancel">
{% trans "Cancel" %}
</a>
<button type="submit" class="btn btn-danger btn-save">
{% trans "Delete" %}
</button>
</div>
</form>
{% endblock %}
@@ -0,0 +1,51 @@
{% extends "pretixcontrol/event/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% block title %}{% trans "Data export" %}{% endblock %}
{% block content %}
<h1>
{% trans "Data export" %}
{% if exporter %}
<small>
{{ exporter.verbose_name }}
</small>
{% endif %}
</h1>
{% if exporter.description %}
<p class="help-block">{{ exporter.description }}</p>
{% endif %}
{% if schedule_form %}
{% bootstrap_form_errors schedule_form layout='control' %}
{% endif %}
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ exporter.identifier }}"/>
<fieldset>
<legend>{% trans "Export options" %}</legend>
{% bootstrap_form exporter.form layout='control' %}
</fieldset>
{% if schedule_form %}
{% include "pretixcontrol/orders/fragment_export_schedule_form.html" %}
<div class="form-group submit-group">
<button formaction="{{ request.get_full_path }}" name="schedule" value="save" type="submit"
class="btn btn-primary btn-save" data-no-asynctask>
{% trans "Save" %}
</button>
</div>
{% else %}
<div class="form-group submit-group">
<button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-download" aria-hidden="true"></span>
{% trans "Start export" %}
</button>
<button formaction="{{ request.get_full_path }}" name="schedule" value="start" type="submit"
class="btn btn-default btn-alternative" data-no-asynctask>
<span class="fa fa-clock-o" aria-hidden="true"></span>
{% trans "Schedule export" %}
</button>
</div>
{% endif %}
</form>
{% endblock %}
@@ -0,0 +1,141 @@
{% load i18n %}
{% load bootstrap3 %}
{% load captureas %}
<fieldset>
<legend>{% trans "Schedule" %}</legend>
{% bootstrap_field schedule_form.schedule_rrule_time layout='control' %}
{% if schedule_form.timezone %}
{% bootstrap_field schedule_form.timezone layout='control' %}
{% endif %}
{% bootstrap_form_errors rrule_form layout='control' %}
{% bootstrap_field rrule_form.dtstart layout="control" %}
<div class="form-group">
<label class="col-md-3 control-label">{% trans "Repetition schedule" %}</label>
<div class="col-md-9">
<div class="form-inline rrule-form">
{% captureas ffield_freq %}
{% bootstrap_field rrule_form.freq layout="inline" %}
{% endcaptureas %}
{% captureas ffield_interval %}
{% bootstrap_field rrule_form.interval layout="inline" %}
{% endcaptureas %}
{% captureas ffield_yearly_bysetpos %}
{% bootstrap_field rrule_form.yearly_bysetpos layout="inline" %}
{% endcaptureas %}
{% captureas ffield_yearly_byweekday %}
{% bootstrap_field rrule_form.yearly_byweekday layout="inline" %}
{% endcaptureas %}
{% captureas ffield_yearly_bymonth %}
{% bootstrap_field rrule_form.yearly_bymonth layout="inline" %}
{% endcaptureas %}
{% captureas ffield_monthly_bysetpos %}
{% bootstrap_field rrule_form.monthly_bysetpos layout="inline" %}
{% endcaptureas %}
{% captureas ffield_monthly_byweekday %}
{% bootstrap_field rrule_form.monthly_byweekday layout="inline" %}
{% endcaptureas %}
{% captureas ffield_count %}
{% bootstrap_field rrule_form.count layout="inline" %}
{% endcaptureas %}
{% captureas ffield_until %}
{% bootstrap_field rrule_form.until layout="inline" %}
{% endcaptureas %}
{% blocktrans trimmed with freq=ffield_freq interval=ffield_interval start=ffield_dtstart %}
Repeat every {{ interval }} {{ freq }}
{% endblocktrans %}<br>
<div class="repeat-yearly">
<div class="radio">
<label>
{{ rrule_form.yearly_same.0 }}
{% trans "At the same date every year" %}
</label><br>
<label>
{{ rrule_form.yearly_same.1 }}
{% blocktrans trimmed with setpos=ffield_yearly_bysetpos weekday=ffield_yearly_byweekday month=ffield_yearly_bymonth %}
On the {{ setpos }} {{ weekday }} of {{ month }}
{% endblocktrans %}<br>
</label>
</div>
</div>
<div class="repeat-monthly">
<div class="radio">
<label>
{{ rrule_form.monthly_same.0 }}
{% trans "At the same date every month" %}
</label><br>
<label>
{{ rrule_form.monthly_same.1 }}
{% blocktrans trimmed with setpos=ffield_monthly_bysetpos weekday=ffield_monthly_byweekday %}
On the {{ setpos }} {{ weekday }}
{% endblocktrans %}<br>
</label>
</div>
</div>
<div class="repeat-weekly">
{% bootstrap_field rrule_form.weekly_byweekday layout="inline" %}
</div>
<div class="repeat-until">
<div class="radio">
<label>
{{ rrule_form.end.0 }}
{% blocktrans trimmed with count=ffield_count %}
Repeat for {{ count }} times
{% endblocktrans %}
</label><br>
<label>
{{ rrule_form.end.1 }}
{% blocktrans trimmed with until=ffield_until %}
Repeat until {{ until }}
{% endblocktrans %}<br>
</label><br>
<label>
{{ rrule_form.end.2 }}
{% blocktrans trimmed %}
Forever
{% endblocktrans %}<br>
</label>
</div>
</div>
</div>
</div>
</div>
</fieldset>
<fieldset>
<legend>{% trans "Email" %}</legend>
<div class="alert alert-info">
{% trans "Every time your schedule is executed, the report will be sent via email." %}
{% trans "Please note the following limitations:" %}
<ul>
<li>
{% trans "Email is not a strongly encrypted medium. We only recommend using this for exports that output e.g. statistical data, not for reports that include sensitive personal data." %}
</li>
<li>
{% trans "Email is not made for large files. If your export ends up to be larger than 20 megabytes, it will not be sent." %}
</li>
</ul>
</div>
<div class="form-group">
<label class="col-md-3 control-label" for="id_schedule-owner">{% trans "Owner" %}</label>
<div class="col-md-9">
<input type="text" name="schedule-owner" value="{{ schedule_form.instance.owner.email }}" disabled
class="form-control" title=""
id="id_schedule-owner">
<div class="help-block">
{% trans "The export will be performed using the owner's permission level, i.e. if the owner loses access to the data, the report will stop." %}
{% trans "The owner will receive the result as well as any error messages." %}
{% trans "The additional recipients you add below will only receive an email if the report was successful." %}
{% trans "All recipients of the export will be able to see who the owner of the report is." %}
</div>
</div>
</div>
{% bootstrap_field schedule_form.mail_additional_recipients layout='control' %}
{% bootstrap_field schedule_form.mail_additional_recipients_cc layout='control' %}
{% bootstrap_field schedule_form.mail_additional_recipients_bcc layout='control' %}
{% bootstrap_field schedule_form.locale layout='control' %}
{% bootstrap_field schedule_form.mail_subject layout='control' %}
{% bootstrap_field schedule_form.mail_template layout='control' %}
</fieldset>
@@ -22,54 +22,72 @@
</h3>
</div>
<div class="panel-body">
<form action="" method="post">
<form action="" method="post" class="row">
{% csrf_token %}
<dl class="dl-horizontal">
<dt>{% trans "Customer ID" %}</dt>
<dd>#{{ customer.identifier }}</dd>
{% if customer.provider %}
<dt>{% trans "SSO provider" %}</dt>
<dd>{{ customer.provider.name }}</dd>
{% endif %}
{% if customer.external_identifier %}
<dt>{% trans "External identifier" %}</dt>
<dd>{{ customer.external_identifier }}</dd>
{% endif %}
<dt>{% trans "Status" %}</dt>
<dd>
{% if not customer.is_active %}
{% trans "disabled" %}
{% elif not customer.is_verified %}
{% trans "not yet activated" %}
{% else %}
{% trans "active" %}
<dl class="dl-horizontal col-lg-6 col-sm-12">
<dt>{% trans "Customer ID" %}</dt>
<dd>#{{ customer.identifier }}</dd>
{% if customer.provider %}
<dt>{% trans "SSO provider" %}</dt>
<dd>{{ customer.provider.name }}</dd>
{% endif %}
</dd>
<dt>{% trans "E-mail" %}</dt>
<dd>
{{ customer.email|default_if_none:"" }}
{% if customer.email and not customer.provider %}
<button type="submit" name="action" value="pwreset" class="btn btn-xs btn-default">
{% trans "Send password reset link" %}
</button>
{% if customer.external_identifier %}
<dt>{% trans "External identifier" %}</dt>
<dd>{{ customer.external_identifier }}</dd>
{% endif %}
</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
{% if customer.phone %}
<dt>{% trans "Phone" %}</dt>
<dd>{{ customer.phone }}</dd>
{% endif %}
<dt>{% trans "Locale" %}</dt>
<dd>{{ display_locale }}</dd>
<dt>{% trans "Registration date" %}</dt>
<dd>{{ customer.date_joined|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Last login" %}</dt>
<dd>{% if customer.last_login %}{{ customer.last_login|date:"SHORT_DATETIME_FORMAT" }}{% else %}
{% endif %}</dd>
{% if customer.notes %}
<dt>{% trans "Notes" %}</dt>
<dd>{{ customer.notes|linebreaks }}</dd>
<dt>{% trans "Status" %}</dt>
<dd>
{% if not customer.is_active %}
{% trans "disabled" %}
{% elif not customer.is_verified %}
{% trans "not yet activated" %}
{% else %}
{% trans "active" %}
{% endif %}
</dd>
<dt>{% trans "E-mail" %}</dt>
<dd>
{{ customer.email|default_if_none:"" }}
{% if customer.email and not customer.provider %}
<button type="submit" name="action" value="pwreset" class="btn btn-xs btn-default">
{% trans "Send password reset link" %}
</button>
{% endif %}
</dd>
<dt>{% trans "Name" %}</dt>
<dd>{{ customer.name }}</dd>
{% if customer.phone %}
<dt>{% trans "Phone" %}</dt>
<dd>{{ customer.phone }}</dd>
{% endif %}
<dt>{% trans "Locale" %}</dt>
<dd>{{ display_locale }}</dd>
<dt>{% trans "Registration date" %}</dt>
<dd>{{ customer.date_joined|date:"SHORT_DATETIME_FORMAT" }}</dd>
<dt>{% trans "Last login" %}</dt>
<dd>{% if customer.last_login %}{{ customer.last_login|date:"SHORT_DATETIME_FORMAT" }}{% else %}
{% endif %}</dd>
{% if customer.notes %}
<dt>{% trans "Notes" %}</dt>
<dd>{{ customer.notes|linebreaks }}</dd>
{% endif %}
</dl>
<dl class="col-lg-6 col-sm-12 text-right">
<dt class="text-muted"
data-toggle="tooltip"
title="{% trans "This includes all paid orders by this customer across all your events." %}">
{% trans "Lifetime spending" %}
</dt>
{% if lifetime_spending %}
{% for s in lifetime_spending %}
{% if s.spending >= 0 %}
<dd class="text-success text-h3">{{ s.spending|money:s.currency }}</dd>
{% elif s.spending < 0 %}
<dd class="text-error text-h3">{{ s.spending|money:s.currency }}</dd>
{% endif %}
{% endfor %}
{% else %}
<dd class="text-muted text-h3">{{ 0|floatformat:2 }}</dd>
{% endif %}
</dl>
</form>
@@ -1,37 +1,110 @@
{% extends "pretixcontrol/event/base.html" %}
{% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %}
{% load bootstrap3 %}
{% load order_overview %}
{% block title %}{% trans "Data export" %}{% endblock %}
{% block content %}
<h1>
<h1>
{% trans "Data export" %}
{% if "identifier" in request.GET %}
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
{% endif %}
</h1>
{% for e in exporters %}
<details class="panel panel-default" {% if "identifier" in request.GET or "exporter" in request.POST %}open{% endif %}>
<summary class="panel-heading">
<h3 class="panel-title">
{{ e.verbose_name }}
<i class="fa fa-angle-down collapse-indicator"></i>
</h3>
</summary>
<div id="{{ e.identifier }}">
<div class="panel-body">
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ e.identifier }}" />
{% bootstrap_form e.form layout='control' %}
<button class="btn btn-primary pull-right flip" type="submit">
<span class="icon icon-upload"></span> {% trans "Start export" %}
</button>
</form>
</div>
</div>
</details>
{% if scheduled %}
<h2>{% trans "Scheduled exports" %}</h2>
<ul class="list-group">
{% for s in scheduled %}
<li class="list-group-item logentry">
<div class="row">
<div class="col-lg-5 col-md-4 col-xs-12">
<span class="fa fa-fw fa-folder"></span>
{{ s.export_verbose_name }}
<br>
<span class="text-muted">
<span class="fa fa-fw fa-user"></span>
{{ s.owner.fullname|default:s.owner.email }}
</span>
</div>
<div class="col-lg-5 col-md-6 col-xs-12">
{% if s.schedule_next_run %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "Next run:" %}
{{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
{% else %}
<span class="fa fa-clock-o fa-fw"></span>
{% trans "No next run scheduled" %}
{% endif %}
{% if s.export_verbose_name == "?" %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Exporter not found" %}
</strong>
{% elif s.error_counter >= 5 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Disabled due to multiple failures" %}
</strong>
{% elif s.error_counter > 0 %}
<strong class="text-danger">
<span class="fa fa-warning fa-fw"></span>
{% trans "Failed recently" %}
</strong>
{% endif %}
<span class="text-muted">
<br>
<span class="fa fa-fw fa-envelope-o"></span>
{{ s.mail_subject }}
</span>
</div>
<div class="col-lg-2 col-md-2 col-xs-12 text-right">
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}"
method="post" class="form-horizontal" data-asynctask data-asynctask-download
data-asynctask-long>
{% csrf_token %}
<input type="hidden" name="exporter" value="{{ s.export_identifier }}"/>
<input type="hidden" name="scheduled" value="{{ s.pk }}"/>
{% if s.export_verbose_name != "?" %}
<button type="submit" class="btn btn-default" title="{% trans "Run export now and download result" %}" data-toggle="tooltip">
<span class="fa fa-download"></span>
</button>
<button formaction="{% url "control:organizer.export.scheduled.run" organizer=request.organizer.slug pk=s.pk %}"
type="submit"
title="{% trans "Run export and send via email now. This will not change the next scheduled execution." %}"
data-toggle="tooltip" class="btn btn-default" data-no-asynctask>
<span class="fa fa-play" aria-hidden="true"></span>
</button>
<a href="?identifier={{ s.export_identifier }}&scheduled={{ s.pk }}" class="btn btn-default" title="{% trans "Edit" %}" data-toggle="tooltip">
<span class="fa fa-edit"></span>
</a>
{% endif %}
<a href="{% url "control:organizer.export.scheduled.delete" organizer=request.organizer.slug pk=s.pk %}" class="btn btn-danger" title="{% trans "Delete" %}" data-toggle="tooltip">
<span class="fa fa-trash"></span>
</a>
</form>
</div>
</div>
</li>
{% endfor %}
</ul>
{% if is_paginated %}
{% include "pretixcontrol/pagination.html" %}
{% endif %}
{% endif %}
{% regroup exporters by category as category_list %}
{% for c, c_ex in category_list %}
{% if c %}
<h2>{{ c }}</h2>
{% else %}
<h2>{% trans "Other exports" %}</h2>
{% endif %}
<div class="list-group large-link-group">
{% for e in c_ex %}
<a class="list-group-item" href="?identifier={{ e.identifier }}">
<h4>{{ e.verbose_name }}</h4>
{% if e.description %}
<p>
{{ e.description }}
</p>
{% endif %}
</a>
{% endfor %}
</div>
{% endfor %}
{% endblock %}

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