Compare commits

...

138 Commits

Author SHA1 Message Date
Raphael Michel
e721f370c4 Bump to 4.16.1 2023-03-06 14:50:09 +01:00
Raphael Michel
19af03c5aa [SECURITY] Enforce session validation on oauth authorize endpoint 2023-03-06 14:49:55 +01:00
Raphael Michel
065e6d4024 Bump version to 4.16.0 2023-01-30 13:50:27 +01:00
Raphael Michel
f99e1dd5be Deprecate MySQL support (#3017) 2023-01-30 13:28:30 +01:00
Raphael Michel
25949c6c2b Translations: Update German (informal) (de_Informal)
Currently translated at 100.0% (5085 of 5085 strings)

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

powered by weblate
2023-01-30 12:53:16 +01:00
Raphael Michel
6fe33077e9 Translations: Update German
Currently translated at 100.0% (5085 of 5085 strings)

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

powered by weblate
2023-01-30 12:53:16 +01:00
Raphael Michel
29b8ee8408 Update po files
[CI skip]

Signed-off-by: Raphael Michel <michel@rami.io>
2023-01-30 12:33:49 +01:00
Christophe Piret
15273ba32e Translations: Update French
Currently translated at 49.7% (2526 of 5081 strings)

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

powered by weblate
2023-01-30 11:54:24 +01:00
Raphael Michel
6ff5b4431c Clean up timezone handling in calendar 2023-01-27 16:45:36 +01:00
Christian Kohlstedde
a82ce69633 Docs: Typo fixes (#3067) 2023-01-27 15:45:09 +01:00
Ismael Menéndez Fernández
53156a4181 Translations: Update Galician
Currently translated at 11.1% (569 of 5081 strings)

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

powered by weblate
2023-01-27 13:48:02 +01:00
Ismael Menéndez Fernández
30142b013e Translations: Update Spanish
Currently translated at 58.1% (2954 of 5081 strings)

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

powered by weblate
2023-01-27 13:48:02 +01:00
Christophe Piret
c4bdfe7537 Translations: Update French
Currently translated at 49.6% (2523 of 5081 strings)

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

powered by weblate
2023-01-27 13:48:02 +01:00
Mossroy
0972123614 Translations: Update French
Currently translated at 49.6% (2523 of 5081 strings)

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

powered by weblate
2023-01-27 13:48:02 +01:00
juliusstoerrle
cf71c4ed2b 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-27 13:48:02 +01:00
Raphael Michel
31e5d00093 Fix typo in best_availability_state computation 2023-01-26 21:32:10 +01:00
Raphael Michel
0eba0f5e3e Organizer index: Fix incorrect display of sold-out events 2023-01-26 15:06:54 +01:00
Richard Schreiber
ce79647289 Shop header: Fix logo link outline to contain image (Z#23115320) 2023-01-26 07:41:50 +01:00
Raphael Michel
acc34c29f7 Box office: SHow payment type "cash" 2023-01-25 17:27:22 +01:00
Raphael Michel
ee6fbbf648 Check-in list: Use new optimized query for present people 2023-01-25 17:16:11 +01:00
Raphael Michel
57fa29a0e9 API: Fix default ordering of check-in list positions 2023-01-25 16:36:08 +01:00
Raphael Michel
5d42dc97c2 API: Use a more sane default ordering for checkin-list 2023-01-25 14:35:20 +01:00
Raphael Michel
ddf0d551f3 Box office payments: Fall back to cardType for ZVT 2023-01-25 12:35:14 +01:00
Raphael Michel
a5570dc475 Checkin: Prefer shorter explanation sin logic explainer 2023-01-25 12:27:27 +01:00
Raphael Michel
3c1f3a26cf Always make explicit which tables to lock (#3058)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-25 11:44:11 +01:00
Raphael Michel
8ca128912e Fix TypeError in failing bulk-checkin-action 2023-01-25 11:17:03 +01:00
Raphael Michel
b9d8429da8 Fix ignored parameter in 9eb2d4301 2023-01-25 10:13:46 +01:00
Raphael Michel
034a32b048 Fix incorrect detail in 9eb2d4301 2023-01-25 10:13:00 +01:00
Raphael Michel
9eb2d43016 Fix performance and logic issues in auto-exit-all 2023-01-25 09:50:36 +01:00
Richard Schreiber
f81b7bcf53 PPv2: fix missing p-tag in payment confirmation 2023-01-24 19:44:36 +01:00
Raphael Michel
234f9d43c5 PPv2: Improve visibility of last step in paying orders (#3046)
Co-authored-by: Martin Gross <gross@rami.io>
2023-01-24 19:28:34 +01:00
Raphael Michel
7f09b4c903 Check-in list: Do not show auto-exits as auto-entries 2023-01-24 18:59:59 +01:00
Raphael Michel
3bc8450d4f Email shredder: Also shred attendee emails and incoming bounces 2023-01-24 18:18:29 +01:00
Raphael Michel
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
Fabian
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 Michel
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 Michel
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 Michel
56d928d5ec Widget: Do not declare products "FREE" if they have mandatory addons (#3041) 2023-01-20 09:15:14 +01:00
Richard Schreiber
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 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 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 Michel
19d1a8de71 Scheduled exports (#3033) 2023-01-19 11:46:30 +01:00
Richard Schreiber
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 Kaag
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 Kaag
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 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 Kaag
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 Kaag
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 Schreiber
93950d3fac Presale: separate multiple lines by comma in ical event location (#3037) 2023-01-17 13:17:58 +01:00
pretix translation bot
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
Raphael Michel
bf4569b080 Exports: Add predefined timeframes (#3027)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-13 13:14:08 +01:00
Aurélia BOUYGE
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
tree
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
tree
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 Schreiber
d0676765a4 Checkout: remove Indonesia from zipcode-validation (#3029) 2023-01-12 16:57:13 +01:00
Raphael Michel
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 Michel
ad69ec293f CI: Use own codecov upload token to prevent rate limit issue (#3028) 2023-01-12 13:36:13 +01:00
Raphael Michel
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 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 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 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 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 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 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 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
Raphael Michel
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 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 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
Raphael Michel
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 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 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 Schreiber
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 Schreiber
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 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 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 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
Raphael Michel
603225d042 Separate personalization from admission (#2990)
Co-authored-by: Richard Schreiber <schreiber@rami.io>
2023-01-09 14:57:35 +01:00
Raphael Michel
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]
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 Dengo
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 Dengo
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
275 changed files with 152412 additions and 111865 deletions

View File

@@ -81,5 +81,6 @@ jobs:
uses: codecov/codecov-action@v1 uses: codecov/codecov-action@v1
with: with:
file: src/coverage.xml file: src/coverage.xml
token: ${{ secrets.CODECOV_TOKEN }}
fail_ci_if_error: true fail_ci_if_error: true
if: matrix.database == 'postgres' && matrix.python-version == '3.10' if: matrix.database == 'postgres' && matrix.python-version == '3.10'

View File

@@ -141,7 +141,7 @@ Database settings
Example:: Example::
[database] [database]
backend=mysql backend=postgresql
name=pretix name=pretix
user=pretix user=pretix
password=abcd password=abcd
@@ -149,7 +149,7 @@ Example::
port=3306 port=3306
``backend`` ``backend``
One of ``mysql``, ``sqlite3``, ``oracle`` and ``postgresql``. One of ``mysql`` (deprecated), ``sqlite3`` and ``postgresql``.
Default: ``sqlite3``. Default: ``sqlite3``.
If you use MySQL, be sure to create your database using If you use MySQL, be sure to create your database using
@@ -163,7 +163,7 @@ Example::
Connection details for the database connection. Empty by default. Connection details for the database connection. Empty by default.
``galera`` ``galera``
Indicates if the database backend is a MySQL/MariaDB Galera cluster and (Deprecated) Indicates if the database backend is a MySQL/MariaDB Galera cluster and
turns on some optimizations/special case handlers. Default: ``False`` turns on some optimizations/special case handlers. Default: ``False``
.. _`config-replica`: .. _`config-replica`:
@@ -194,7 +194,7 @@ Example::
[urls] [urls]
media=/media/ media=/media/
static=/media/ static=/static/
``media`` ``media``
The URL to be used to serve user-uploaded content. You should not need to modify The URL to be used to serve user-uploaded content. You should not need to modify

View File

@@ -14,4 +14,5 @@ This documentation is for everyone who wants to install pretix on a server.
maintainance maintainance
scaling scaling
errors errors
mysql2postgres
indexes indexes

View File

@@ -14,7 +14,7 @@ This has some trade-offs in terms of performance and isolation but allows a rath
get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
offers at `pretix.eu`_. offers at `pretix.eu`_.
We tested this guide on the Linux distribution **Debian 8.0** but it should work very similar on other We tested this guide on the Linux distribution **Debian 11.0** but it should work very similar on other
modern distributions, especially on all systemd-based ones. modern distributions, especially on all systemd-based ones.
Requirements Requirements
@@ -26,7 +26,7 @@ installation guides):
* `Docker`_ * `Docker`_
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for * A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections * A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server * A `PostgreSQL`_ 9.6+ database server
* A `redis`_ server * A `redis`_ server
We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to We also recommend that you use a firewall, although this is not a pretix-specific recommendation. If you're new to
@@ -58,9 +58,6 @@ directory writable to the user that runs pretix inside the docker container::
Database Database
-------- --------
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on Next, we need a database and a database user. We can create these with any kind of database managing tool or directly on
our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with our database's shell. Please make sure that UTF8 is used as encoding for the best compatibility. You can check this with
the following command:: the following command::
@@ -86,13 +83,6 @@ Restart PostgreSQL after you changed these files::
If you have a firewall running, you should also make sure that port 5432 is reachable from the ``172.17.0.1/16`` subnet. If you have a firewall running, you should also make sure that port 5432 is reachable from the ``172.17.0.1/16`` subnet.
For MySQL, you can either also use network-based connections or mount the ``/var/run/mysqld/mysqld.sock`` socket into the docker container.
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
Redis Redis
----- -----
@@ -152,15 +142,13 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
trust_x_forwarded_proto=on trust_x_forwarded_proto=on
[database] [database]
; Replace postgresql with mysql for MySQL
backend=postgresql backend=postgresql
name=pretix name=pretix
user=pretix user=pretix
; Replace with the password you chose above ; Replace with the password you chose above
password=********* password=*********
; In most docker setups, 172.17.0.1 is the address of the docker host. Adjust ; In most docker setups, 172.17.0.1 is the address of the docker host. Adjust
; this to wherever your database is running, e.g. the name of a linked container ; this to wherever your database is running, e.g. the name of a linked container.
; or of a mounted MySQL socket.
host=172.17.0.1 host=172.17.0.1
[mail] [mail]
@@ -212,8 +200,6 @@ named ``/etc/systemd/system/pretix.service`` with the following content::
[Install] [Install]
WantedBy=multi-user.target WantedBy=multi-user.target
When using MySQL and socket mounting, you'll need the additional flag ``-v /var/run/mysqld:/var/run/mysqld`` in the command.
You can now run the following commands You can now run the following commands
to enable and start the service:: to enable and start the service::
@@ -339,7 +325,6 @@ workers, e.g. ``docker run … taskworker -Q notifications --concurrency 32``.
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/ .. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
.. _pretix.eu: https://pretix.eu/ .. _pretix.eu: https://pretix.eu/
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04 .. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
.. _redis: https://blog.programster.org/debian-8-install-redis-server/ .. _redis: https://blog.programster.org/debian-8-install-redis-server/
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall .. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall

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 get it right. If you're not feeling comfortable managing a Linux server, check out our hosting and service
offers at `pretix.eu`_. 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. modern distributions, especially on all systemd-based ones.
Requirements Requirements
@@ -23,7 +23,7 @@ installation guides):
* A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for * A SMTP server to send out mails, e.g. `Postfix`_ on your machine or some third-party server you have credentials for
* A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections * A HTTP reverse proxy, e.g. `nginx`_ or Apache to allow HTTPS connections
* A `PostgreSQL`_ 9.6+, `MySQL`_ 5.7+, or MariaDB 10.2.7+ database server * A `PostgreSQL`_ 9.6+ database server
* A `redis`_ server * A `redis`_ server
* A `nodejs`_ installation * A `nodejs`_ installation
@@ -47,9 +47,6 @@ In this guide, all code lines prepended with a ``#`` symbol are commands that yo
Database Database
-------- --------
.. warning:: **Please use PostgreSQL for all new installations**. If you need to go for MySQL, make sure you run
**MySQL 5.7 or newer** or **MariaDB 10.2.7 or newer**.
Having the database server installed, we still need a database and a database user. We can create these with any kind Having the database server installed, we still need a database and a database user. We can create these with any kind
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
best compatibility. You can check this with the following command:: best compatibility. You can check this with the following command::
@@ -61,12 +58,6 @@ For PostgreSQL database creation, we would do::
# sudo -u postgres createuser pretix # sudo -u postgres createuser pretix
# sudo -u postgres createdb -O pretix pretix # sudo -u postgres createdb -O pretix pretix
When using MySQL, make sure you set the character set of the database to ``utf8mb4``, e.g. like this::
mysql > CREATE DATABASE pretix DEFAULT CHARACTER SET utf8mb4 DEFAULT COLLATE utf8mb4_unicode_ci;
You will also need to make sure that ``sql_mode`` in your ``my.cnf`` file does **not** include ``ONLY_FULL_GROUP_BY``.
Package dependencies Package dependencies
-------------------- --------------------
@@ -74,7 +65,7 @@ To build and run pretix, you will need the following debian packages::
# apt-get install git build-essential python-dev python3-venv python3 python3-pip \ # apt-get install git build-essential python-dev python3-venv python3 python3-pip \
python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \ python3-dev libxml2-dev libxslt1-dev libffi-dev zlib1g-dev libssl-dev \
gettext libpq-dev libmariadb-dev libjpeg-dev libopenjp2-7-dev gettext libpq-dev libjpeg-dev libopenjp2-7-dev
Config file Config file
----------- -----------
@@ -97,16 +88,12 @@ Fill the configuration file ``/etc/pretix/pretix.cfg`` with the following conten
trust_x_forwarded_proto=on trust_x_forwarded_proto=on
[database] [database]
; For MySQL, replace with "mysql"
backend=postgresql backend=postgresql
name=pretix name=pretix
user=pretix user=pretix
; For MySQL, enter the user password. For PostgreSQL on the same host, ; For PostgreSQL on the same host, we don't need a password because we can use
; we don't need one because we can use peer authentification if our ; peer authentication if our PostgreSQL user matches our unix user.
; PostgreSQL user matches our unix user.
password= password=
; For MySQL, use local socket, e.g. /var/run/mysqld/mysqld.sock
; For a remote host, supply an IP address
; For local postgres authentication, you can leave it empty ; For local postgres authentication, you can leave it empty
host= host=
@@ -140,10 +127,6 @@ We now install pretix, its direct dependencies and gunicorn::
(venv)$ pip3 install pretix gunicorn (venv)$ pip3 install pretix gunicorn
If you're running MySQL, also install the client library::
(venv)$ pip3 install mysqlclient
Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``. Note that you need Python 3.7 or newer. You can find out your Python version using ``python -V``.
We also need to create a data directory:: We also need to create a data directory::
@@ -344,7 +327,6 @@ Then, proceed like after any plugin installation::
.. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/ .. _nginx: https://botleg.com/stories/https-with-lets-encrypt-and-nginx/
.. _Let's Encrypt: https://letsencrypt.org/ .. _Let's Encrypt: https://letsencrypt.org/
.. _pretix.eu: https://pretix.eu/ .. _pretix.eu: https://pretix.eu/
.. _MySQL: https://dev.mysql.com/doc/refman/5.7/en/linux-installation-apt-repo.html
.. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04 .. _PostgreSQL: https://www.digitalocean.com/community/tutorials/how-to-install-and-use-postgresql-on-ubuntu-20-04
.. _redis: https://blog.programster.org/debian-8-install-redis-server/ .. _redis: https://blog.programster.org/debian-8-install-redis-server/
.. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall .. _ufw: https://en.wikipedia.org/wiki/Uncomplicated_Firewall

View File

@@ -17,11 +17,11 @@ Backups
There are essentially two things which you should create backups of: There are essentially two things which you should create backups of:
Database Database
Your SQL database (MySQL or PostgreSQL). This is critical and you should **absolutely Your SQL database. This is critical and you should **absolutely always create automatic
always create automatic backups of your database**. There are tons of tutorials on the backups of your database**. There are tons of tutorials on the internet on how to do this,
internet on how to do this, and the exact process depends on the choice of your database. and the exact process depends on the choice of your database. For PostgreSQL, see the
For MySQL, see ``mysqldump`` and for PostgreSQL, see the ``pg_dump`` tool. You probably ``pg_dump`` tool. You probably want to create a cronjob that does the backups for you on a
want to create a cronjob that does the backups for you on a regular schedule. regular schedule.
Data directory Data directory
The data directory of your pretix configuration might contain some things that you should The data directory of your pretix configuration might contain some things that you should

View File

@@ -0,0 +1,148 @@
.. highlight:: none
Migrating from MySQL/MariaDB to PostgreSQL
==========================================
Our recommended database for all production installations is PostgreSQL. Support for MySQL/MariaDB will be removed in
pretix 5.0.
In order to follow this guide, your pretix installation needs to be a version that fully supports MySQL/MariaDB. If you
already upgraded to pretix 5.0, downgrade back to the last 4.x release using ``pip``.
.. note:: We have tested this guide carefully, but we can't assume any liability for its correctness. The data loss
risk should be low as long as pretix is not running while you do the migration. If you are a pretix Enterprise
customer, feel free to reach out in advance if you want us to support you along the way.
Update database schema
----------------------
Before you start, make sure your database schema is up to date::
# sudo -u pretix -s
$ source /var/pretix/venv/bin/activate
(venv)$ python -m pretix migrate
Install PostgreSQL
------------------
Now, install and set up a PostgreSQL server. For a local installation on Debian or Ubuntu, use::
# apt install postgresql
Having the database server installed, we still need a database and a database user. We can create these with any kind
of database managing tool or directly on our database's shell. Please make sure that UTF8 is used as encoding for the
best compatibility. You can check this with the following command::
# sudo -u postgres psql -c 'SHOW SERVER_ENCODING'
Without Docker
""""""""""""""
For our standard manual installation, create the database and user like this::
# sudo -u postgres createuser pretix
# sudo -u postgres createdb -O pretix pretix
With Docker
"""""""""""
For our standard docker installation, create the database and user like this::
# sudo -u postgres createuser -P pretix
# sudo -u postgres createdb -O pretix pretix
Make sure that your database listens on the network. If PostgreSQL on the same same host as docker, but not inside a docker container, we recommend that you just listen on the Docker interface by changing the following line in ``/etc/postgresql/<version>/main/postgresql.conf``::
listen_addresses = 'localhost,172.17.0.1'
You also need to add a new line to ``/etc/postgresql/<version>/main/pg_hba.conf`` to allow network connections to this user and database::
host pretix pretix 172.17.0.1/16 md5
Restart PostgreSQL after you changed these files::
# systemctl restart postgresql
If you have a firewall running, you should also make sure that port 5432 is reachable from the ``172.17.0.1/16`` subnet.
Of course, instead of all this you can also run a PostgreSQL docker container and link it to the pretix container.
Stop pretix
-----------
To prevent any more changes to your data, stop pretix from running::
# systemctl stop pretix-web pretix-worker
Change configuration
--------------------
Change the database configuration in your ``/etc/pretix/pretix.cfg`` file::
[database]
backend=postgresql
name=pretix
user=pretix
password= ; only required for docker or remote database, can be kept empty for local auth
host= ; set to 172.17.0.1 in docker setup, keep empty for local auth
Create database schema
-----------------------
To create the schema in your new PostgreSQL database, use the following commands::
# sudo -u pretix -s
$ source /var/pretix/venv/bin/activate
(venv)$ python -m pretix migrate
Migrate your data
-----------------
Install ``pgloader``::
# apt install pgloader
Create a new file ``/tmp/pretix.load``, replacing the MySQL and PostgreSQL connection strings with the correct user names, passwords, and/or database names::
LOAD DATABASE
FROM mysql://pretix:password@localhost/pretix -- replace with mysql://username:password@hostname/dbname
INTO postgresql:///pretix -- replace with dbname
WITH data only, include no drop, truncate, disable triggers,
create no indexes, drop indexes, reset sequences
ALTER SCHEMA 'pretix' RENAME TO 'public' -- replace pretix with the name of the MySQL database
ALTER TABLE NAMES MATCHING ~/.*/
SET SCHEMA 'public'
SET timezone TO '+00:00'
SET PostgreSQL PARAMETERS
maintenance_work_mem to '128MB',
work_mem to '12MB';
Then, run::
# sudo -u postgres pgloader /tmp/pretix.load
The output should end with a table summarizing the results for every table. You can ignore warnings about type casts
and missing constraints.
Afterwards, delete the file again::
# rm -rf /tmp/pretix.load
Start pretix
------------
Now, restart pretix. Maybe stop your MySQL server as a verification step that you are no longer using it::
# systemctl stop mariadb
# systemctl start pretix-web pretix-worker
And you're done! After you've verified everything has been copied correctly, you can delete the old MySQL database.
.. note:: Don't forget to update your backup process to back up your PostgreSQL database instead of your MySQL database now.

View File

@@ -42,7 +42,7 @@ A pretix installation usually consists of the following components which run per
* ``pretix-worker`` is a Celery-based application that processes tasks that should be run asynchronously outside of the web application process. * ``pretix-worker`` is a Celery-based application that processes tasks that should be run asynchronously outside of the web application process.
* A **SQL database** keeps all the important data and processes the actual transactions. We recommend using PostgreSQL, but MySQL/MariaDB works as well. * A **PostgreSQL database** keeps all the important data and processes the actual transactions.
* A **web server** that terminates TLS and HTTP connections and forwards them to ``pretix-web``. In some cases, e.g. when serving static files, the web servers might return a response directly. We recommend using ``nginx``. * A **web server** that terminates TLS and HTTP connections and forwards them to ``pretix-web``. In some cases, e.g. when serving static files, the web servers might return a response directly. We recommend using ``nginx``.
@@ -74,7 +74,7 @@ We recommend reading up on tuning your web server for high concurrency. For ngin
processes and the number of connections each worker process accepts. Double-check that TLS session caching works, because TLS processes and the number of connections each worker process accepts. Double-check that TLS session caching works, because TLS
handshakes can get really expensive. handshakes can get really expensive.
During a traffic peak, your web server will be able to make us of more CPU resources, while memory usage will stay comparatively low, During a traffic peak, your web server will be able to make use of more CPU resources, while memory usage will stay comparatively low,
so if you invest in more hardware here, invest in more and faster CPU cores. so if you invest in more hardware here, invest in more and faster CPU cores.
Make sure that pretix' static files (such as CSS and JavaScript assets) as well as user-uploaded media files (event logos, etc) Make sure that pretix' static files (such as CSS and JavaScript assets) as well as user-uploaded media files (event logos, etc)

View File

@@ -192,6 +192,9 @@ Relative date *either* String in ISO 8601 ``"2017-12-27"``,
File URL in responses, ``file:`` ``"https://…"``, ``"file:…"`` File URL in responses, ``file:`` ``"https://…"``, ``"file:…"``
specifiers in requests specifiers in requests
(see below). (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 Query parameters

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 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 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 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 organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch :param event: The ``slug`` field of the event to fetch
:statuscode 200: no error :statuscode 200: no error

View File

@@ -35,6 +35,12 @@ tax_rule integer The internal ID
admission boolean ``true`` for items that grant admission to the event admission boolean ``true`` for items that grant admission to the event
(such as primary tickets) and ``false`` for others (such as primary tickets) and ``false`` for others
(such as add-ons or merchandise). (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 position integer An integer, used for sorting
picture file A product picture to be displayed in the shop picture file A product picture to be displayed in the shop
(can be ``null``). (can be ``null``).
@@ -158,7 +164,7 @@ meta_data object Values set for
.. versionchanged:: 4.16 .. versionchanged:: 4.16
The ``variations[x].meta_data`` attribute has been added. The ``variations[x].meta_data`` attribute has been added. The ``personalized`` attribute has been added.
Notes Notes
----- -----
@@ -213,6 +219,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
@@ -329,6 +336,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
@@ -426,6 +434,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
@@ -510,6 +519,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,
@@ -626,6 +636,7 @@ Endpoints
"tax_rate": "0.00", "tax_rate": "0.00",
"tax_rule": 1, "tax_rule": 1,
"admission": false, "admission": false,
"personalized": false,
"issue_giftcard": false, "issue_giftcard": false,
"meta_data": {}, "meta_data": {},
"position": 0, "position": 0,

View File

@@ -76,6 +76,10 @@ The exporter class
This is an abstract attribute, you **must** override this! This is an abstract attribute, you **must** override this!
.. autoattribute:: description
.. autoattribute:: category
.. autoattribute:: export_form_fields .. autoattribute:: export_form_fields
.. automethod:: render .. automethod:: render

View File

@@ -17,9 +17,13 @@ Field Type Description
id integer Internal layout ID id integer Internal layout ID
name string Internal layout description name string Internal layout description
default boolean ``true`` if this is the default layout 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 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``). ├ sales_channel string Sales channel (defaults to ``web``).
└ item integer Item ID └ item integer Item ID
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -58,7 +62,7 @@ Endpoints
"name": "Default layout", "name": "Default layout",
"default": true, "default": true,
"layout": {…}, "layout": {…},
"background": {}, "background": null,
"item_assignments": [] "item_assignments": []
} }
] ]
@@ -96,7 +100,7 @@ Endpoints
"name": "Default layout", "name": "Default layout",
"default": true, "default": true,
"layout": {…}, "layout": {…},
"background": {}, "background": null,
"item_assignments": [] "item_assignments": []
} }
@@ -147,3 +151,122 @@ Endpoints
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view it. :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

View File

@@ -97,6 +97,7 @@ overpayment
param param
passphrase passphrase
percental percental
personalization
pluggable pluggable
positionid positionid
pre pre

View File

@@ -19,4 +19,4 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
__version__ = "4.16.0.dev0" __version__ = "4.16.1"

View File

@@ -32,6 +32,7 @@ from rest_framework import status
from pretix.api.models import ApiCall from pretix.api.models import ApiCall
from pretix.base.models import Organizer from pretix.base.models import Organizer
from pretix.helpers import OF_SELF
class IdempotencyMiddleware: class IdempotencyMiddleware:
@@ -56,7 +57,7 @@ class IdempotencyMiddleware:
idempotency_key = request.headers.get('X-Idempotency-Key', '') idempotency_key = request.headers.get('X-Idempotency-Key', '')
with transaction.atomic(): with transaction.atomic():
call, created = ApiCall.objects.select_for_update().get_or_create( call, created = ApiCall.objects.select_for_update(of=OF_SELF).get_or_create(
auth_hash=auth_hash, auth_hash=auth_hash,
idempotency_key=idempotency_key, idempotency_key=idempotency_key,
defaults={ defaults={

View File

@@ -22,8 +22,10 @@
from django import forms from django import forms
from django.http import QueryDict from django.http import QueryDict
from rest_framework import serializers from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.base.exporter import OrganizerLevelExportMixin from pretix.base.exporter import OrganizerLevelExportMixin
from pretix.base.timeframes import DateFrameField, SerializerDateFrameField
class FormFieldWrapperField(serializers.Field): class FormFieldWrapperField(serializers.Field):
@@ -142,6 +144,12 @@ class JobRunSerializer(serializers.Serializer):
allow_null=not v.required, allow_null=not v.required,
validators=v.validators, validators=v.validators,
) )
elif isinstance(v, DateFrameField):
self.fields[k] = SerializerDateFrameField(
required=v.required,
allow_null=not v.required,
validators=v.validators,
)
else: else:
self.fields[k] = FormFieldWrapperField(form_field=v, required=v.required, allow_null=not v.required) 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(): for k, v in self.fields.items():
if isinstance(v, serializers.ManyRelatedField) and k not in data: if isinstance(v, serializers.ManyRelatedField) and k not in data:
data[k] = [] 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) data = super().to_internal_value(data)
return 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)

View File

@@ -95,8 +95,12 @@ class ItemVariationSerializer(I18nAwareModelSerializer):
@transaction.atomic @transaction.atomic
def create(self, validated_data): def create(self, validated_data):
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
require_membership_types = validated_data.pop('require_membership_types', [])
variation = ItemVariation.objects.create(**validated_data) variation = ItemVariation.objects.create(**validated_data)
if require_membership_types:
variation.require_membership_types.add(*require_membership_types)
# Meta data # Meta data
if meta_data is not None: if meta_data is not None:
for key, value in meta_data.items(): for key, value in meta_data.items():
@@ -230,7 +234,7 @@ class ItemSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Item model = Item
fields = ('id', 'category', 'name', 'internal_name', 'active', 'sales_channels', 'description', fields = ('id', 'category', 'name', 'internal_name', 'active', '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', 'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling', 'require_voucher', 'hide_without_voucher', 'allow_cancel', 'require_bundling',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations', 'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations', 'variations',
@@ -258,6 +262,15 @@ class ItemSerializer(I18nAwareModelSerializer):
Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order')) Item.clean_per_order(data.get('min_per_order'), data.get('max_per_order'))
Item.clean_available(data.get('available_from'), data.get('available_until')) Item.clean_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('issue_giftcard'):
if data.get('tax_rule') and data.get('tax_rule').rate > 0: if data.get('tax_rule') and data.get('tax_rule').rate > 0:
raise ValidationError( raise ValidationError(

View File

@@ -118,6 +118,10 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
raise ValidationError( raise ValidationError(
{'name': ['Do not specify name if you specified name_parts.']} {'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'): if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
@@ -841,6 +845,10 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
raise ValidationError( raise ValidationError(
{'attendee_name': ['Do not specify attendee_name if you specified attendee_name_parts.']} {'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'): 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 data['attendee_name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme

View File

@@ -158,12 +158,14 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
a.question_id: a for a in instance.answers.all() a.question_id: a for a in instance.answers.all()
} }
for answ_data in answers_data: for answ_data in answers_data:
if not answ_data.get('answer'):
continue
options = answ_data.pop('options', []) options = answ_data.pop('options', [])
if answ_data['question'].pk in qs_seen: if answ_data['question'].pk in qs_seen:
raise ValidationError(f'Question {answ_data["question"]} was sent twice.') raise ValidationError(f'Question {answ_data["question"]} was sent twice.')
if answ_data['question'].pk in answercache: if answ_data['question'].pk in answercache:
a = answercache[answ_data['question'].pk] 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.file.save(answ_data['answer'].name, answ_data['answer'], save=False)
a.answer = 'file://' + a.file.name a.answer = 'file://' + a.file.name
elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep": elif a.answer.startswith('file://') and answ_data['answer'] == "file:keep":
@@ -173,7 +175,7 @@ class OrderPositionInfoPatchSerializer(serializers.ModelSerializer):
setattr(a, attr, value) setattr(a, attr, value)
a.save() a.save()
else: else:
if isinstance(answ_data['answer'], File): if isinstance(answ_data.get('answer'), File):
an = answ_data.pop('answer') an = answ_data.pop('answer')
a = instance.answers.create(**answ_data, answer='') a = instance.answers.create(**answ_data, answer='')
a.file.save(os.path.basename(an.name), an, save=False) a.file.save(os.path.basename(an.name), an, save=False)

View File

@@ -79,6 +79,13 @@ class CustomerSerializer(I18nAwareModelSerializer):
validated_data['external_identifier'] = instance.external_identifier validated_data['external_identifier'] = instance.external_identifier
return super().update(instance, validated_data) 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): class CustomerCreateSerializer(CustomerSerializer):
send_email = serializers.BooleanField(default=False, required=False, allow_null=True) send_email = serializers.BooleanField(default=False, required=False, allow_null=True)

View File

@@ -93,8 +93,10 @@ with scopes_disabled():
class CheckinListViewSet(viewsets.ModelViewSet): class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer serializer_class = CheckinListSerializer
queryset = CheckinList.objects.none() queryset = CheckinList.objects.none()
filter_backends = (DjangoFilterBackend,) filter_backends = (DjangoFilterBackend, RichOrderingFilter)
filterset_class = CheckinListFilter filterset_class = CheckinListFilter
ordering = ('subevent__date_from', 'name', 'id')
ordering_fields = ('subevent__date_from', 'id', 'name',)
def _get_permission_name(self, request): def _get_permission_name(self, request):
if request.path.endswith('/failed_checkins/'): if request.path.endswith('/failed_checkins/'):
@@ -682,7 +684,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.all.none() queryset = OrderPosition.all.none()
filter_backends = (ExtendedBackend, RichOrderingFilter) filter_backends = (ExtendedBackend, RichOrderingFilter)
ordering = (F('attendee_name_cached').asc(nulls_last=True), 'positionid') ordering = (F('attendee_name_cached').asc(nulls_last=True), 'pk')
ordering_fields = ( ordering_fields = (
'order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__code', 'order__datetime', 'positionid', 'attendee_name',
'last_checked_in', 'order__email', 'last_checked_in', 'order__email',

View File

@@ -34,6 +34,7 @@ from oauth2_provider.views import (
from pretix.api.models import OAuthApplication from pretix.api.models import OAuthApplication
from pretix.base.models import Organizer from pretix.base.models import Organizer
from pretix.control.views.user import RecentAuthenticationRequiredMixin
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -54,7 +55,7 @@ class OAuthAllowForm(AllowForm):
del self.fields['organizers'] del self.fields['organizers']
class AuthorizationView(BaseAuthorizationView): class AuthorizationView(RecentAuthenticationRequiredMixin, BaseAuthorizationView):
template_name = "pretixcontrol/auth/oauth_authorization.html" template_name = "pretixcontrol/auth/oauth_authorization.html"
form_class = OAuthAllowForm form_class = OAuthAllowForm

View File

@@ -51,6 +51,7 @@ from pretix.base.models import (
User, User,
) )
from pretix.base.settings import SETTINGS_AFFECTING_CSS from pretix.base.settings import SETTINGS_AFFECTING_CSS
from pretix.helpers import OF_SELF
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
from pretix.presale.style import regenerate_organizer_css from pretix.presale.style import regenerate_organizer_css
@@ -178,7 +179,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
def perform_update(self, serializer): def perform_update(self, serializer):
if 'include_accepted' in self.request.GET: if 'include_accepted' in self.request.GET:
raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.") raise PermissionDenied("Accepted gift cards cannot be updated, use transact instead.")
GiftCard.objects.select_for_update().get(pk=self.get_object().pk) GiftCard.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
old_value = serializer.instance.value old_value = serializer.instance.value
value = serializer.validated_data.pop('value') value = serializer.validated_data.pop('value')
inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency, inst = serializer.save(secret=serializer.instance.secret, currency=serializer.instance.currency,
@@ -196,7 +197,7 @@ class GiftCardViewSet(viewsets.ModelViewSet):
@action(detail=True, methods=["POST"]) @action(detail=True, methods=["POST"])
@transaction.atomic() @transaction.atomic()
def transact(self, request, **kwargs): def transact(self, request, **kwargs):
gc = GiftCard.objects.select_for_update().get(pk=self.get_object().pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=self.get_object().pk)
value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value( value = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
request.data.get('value') request.data.get('value')
) )

View File

@@ -21,6 +21,7 @@
# #
import datetime import datetime
from django.core.exceptions import ValidationError as DjangoValidationError
from django.utils.timezone import now from django.utils.timezone import now
from oauth2_provider.contrib.rest_framework import OAuth2Authentication from oauth2_provider.contrib.rest_framework import OAuth2Authentication
from rest_framework.authentication import SessionAuthentication 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.permission import AnyAuthenticatedClientPermission
from pretix.api.auth.token import TeamTokenAuthentication from pretix.api.auth.token import TeamTokenAuthentication
from pretix.base.models import CachedFile from pretix.base.models import CachedFile
from pretix.helpers.images import (
IMAGE_TYPES, validate_uploaded_file_for_valid_image,
)
ALLOWED_TYPES = { ALLOWED_TYPES = {
'image/gif': {'.gif'}, 'image/gif': {'.gif'},
@@ -61,6 +65,13 @@ class UploadView(APIView):
name=file_obj.name, name=file_obj.name,
type=content_type 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( cf = CachedFile.objects.create(
expires=now() + datetime.timedelta(days=1), expires=now() + datetime.timedelta(days=1),
date=now(), date=now(),

View File

@@ -42,6 +42,7 @@ from pretix.base.models import LogEntry
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.base.signals import periodic_task from pretix.base.signals import periodic_task
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
_ALL_EVENTS = None _ALL_EVENTS = None
@@ -502,7 +503,8 @@ def manually_retry_all_calls(webhook_id: int):
webhook = WebHook.objects.get(id=webhook_id) webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer), transaction.atomic(): with scope(organizer=webhook.organizer), transaction.atomic():
for whcr in webhook.retries.select_for_update( for whcr in webhook.retries.select_for_update(
skip_locked=connection.features.has_select_for_update_skip_locked skip_locked=connection.features.has_select_for_update_skip_locked,
of=OF_SELF
): ):
send_webhook.apply_async( send_webhook.apply_async(
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count), args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),
@@ -515,7 +517,8 @@ def manually_retry_all_calls(webhook_id: int):
def schedule_webhook_retries_on_celery(sender, **kwargs): def schedule_webhook_retries_on_celery(sender, **kwargs):
with transaction.atomic(): with transaction.atomic():
for whcr in WebHookCallRetry.objects.select_for_update( for whcr in WebHookCallRetry.objects.select_for_update(
skip_locked=connection.features.has_select_for_update_skip_locked skip_locked=connection.features.has_select_for_update_skip_locked,
of=OF_SELF
).filter(retry_not_before__lt=now()): ).filter(retry_not_before__lt=now()):
send_webhook.apply_async( send_webhook.apply_async(
args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count), args=(whcr.logentry_id, whcr.action_type, whcr.webhook_id, whcr.retry_count),

View File

@@ -42,7 +42,6 @@ from localflavor.fr.forms import FRZipCodeField
from localflavor.gb.forms import GBPostcodeField from localflavor.gb.forms import GBPostcodeField
from localflavor.gr.forms import GRPostalCodeField from localflavor.gr.forms import GRPostalCodeField
from localflavor.hr.forms import HRPostalCodeField from localflavor.hr.forms import HRPostalCodeField
from localflavor.id_.forms import IDPostCodeField
from localflavor.ie.forms import EircodeField from localflavor.ie.forms import EircodeField
from localflavor.il.forms import ILPostalCodeField from localflavor.il.forms import ILPostalCodeField
from localflavor.in_.forms import INZipCodeField from localflavor.in_.forms import INZipCodeField
@@ -80,8 +79,8 @@ 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 # We don't presume this for countries we don't have knowledge about, there are countries in the
# world e.g. without zipcodes # world e.g. without zipcodes
'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR', 'AR', 'AT', 'AU', 'BE', 'BR', 'CA', 'CH', 'CN', 'CU', 'CZ', 'DE', 'DK', 'EE', 'ES', 'FI', 'FR',
'GB', 'GR', 'HR', 'ID', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX', 'GB', 'GR', 'HR', 'IE', 'IL', 'IN', 'IR', 'IS', 'IT', 'JP', 'LT', 'LV', 'MA', 'MT', 'MX', 'NL',
'NL', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA', 'NO', 'NZ', 'PK', 'PL', 'PT', 'RO', 'RU', 'SE', 'SG', 'SI', 'SK', 'TR', 'UA', 'US', 'ZA',
} }
@@ -167,7 +166,6 @@ _zip_code_fields = {
'GB': GBPostcodeField, 'GB': GBPostcodeField,
'GR': GRPostalCodeField, 'GR': GRPostalCodeField,
'HR': HRPostalCodeField, 'HR': HRPostalCodeField,
'ID': IDPostCodeField,
'IE': EircodeField, 'IE': EircodeField,
'IL': ILPostalCodeField, 'IL': ILPostalCodeField,
'IN': INZipCodeField, 'IN': INZipCodeField,

View File

@@ -520,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() lambda event: (event if not event.has_subevents or not event.subevents.exists() else event.subevents.first()).get_date_from_display()
), ),
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(
'url_remove', ['waiting_list_entry', 'event'], 'url_remove', ['waiting_list_voucher', 'event'],
lambda waiting_list_entry, event: build_absolute_uri( lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.waitinglist.remove' event, 'presale:event.waitinglist.remove'
) + '?voucher=' + waiting_list_entry.voucher.code, ) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri( lambda event: build_absolute_uri(
event, event,
'presale:event.waitinglist.remove', 'presale:event.waitinglist.remove',
) + '?voucher=68CYU2H6ZTP3WLK5', ) + '?voucher=68CYU2H6ZTP3WLK5',
), ),
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(
'url', ['waiting_list_entry', 'event'], 'url', ['waiting_list_voucher', 'event'],
lambda waiting_list_entry, event: build_absolute_uri( lambda waiting_list_voucher, event: build_absolute_uri(
event, 'presale:event.redeem' event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_entry.voucher.code, ) + '?voucher=' + waiting_list_voucher.code,
lambda event: build_absolute_uri( lambda event: build_absolute_uri(
event, event,
'presale:event.redeem', 'presale:event.redeem',
@@ -588,7 +588,7 @@ def base_placeholders(sender, **kwargs):
_('Sample Admission Ticket') _('Sample Admission Ticket')
), ),
SimpleFunctionalMailTextPlaceholder( 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' '68CYU2H6ZTP3WLK5'
), ),
SimpleFunctionalMailTextPlaceholder( SimpleFunctionalMailTextPlaceholder(

View File

@@ -36,7 +36,7 @@ import io
import tempfile import tempfile
from collections import OrderedDict, namedtuple from collections import OrderedDict, namedtuple
from decimal import Decimal from decimal import Decimal
from typing import Tuple from typing import Optional, Tuple
import pytz import pytz
from defusedcsv import csv from defusedcsv import csv
@@ -84,6 +84,27 @@ class BaseExporter:
""" """
raise NotImplementedError() # NOQA 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 @property
def identifier(self) -> str: def identifier(self) -> str:
""" """

View File

@@ -39,7 +39,7 @@ from zipfile import ZipFile
from django import forms from django import forms
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import QuestionAnswer from pretix.base.models import QuestionAnswer
@@ -49,7 +49,10 @@ from ..signals import register_data_exporters
class AnswerFilesExporter(BaseExporter): class AnswerFilesExporter(BaseExporter):
identifier = 'answerfiles' 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 @property
def export_form_fields(self): def export_form_fields(self):

View File

@@ -36,7 +36,7 @@ from collections import OrderedDict
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import get_current_timezone 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 from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -48,6 +48,8 @@ class CustomerListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'customerlist' identifier = 'customerlist'
verbose_name = gettext_lazy('Customer accounts') verbose_name = gettext_lazy('Customer accounts')
organizer_required_permission = 'can_manage_customers' 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 @property
def additional_form_fields(self): def additional_form_fields(self):

View File

@@ -23,22 +23,24 @@ import json
from collections import OrderedDict from collections import OrderedDict
from decimal import Decimal from decimal import Decimal
import dateutil
from django import forms
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.dispatch import receiver 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.i18n import language
from pretix.base.models import Invoice, OrderPayment from pretix.base.models import Invoice, OrderPayment
from ..exporter import BaseExporter from ..exporter import BaseExporter
from ..signals import register_data_exporters from ..signals import register_data_exporters
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class DekodiNREIExporter(BaseExporter): class DekodiNREIExporter(BaseExporter):
identifier = 'dekodi_nrei' identifier = 'dekodi_nrei'
verbose_name = 'dekodi NREI (JSON)' 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/ # Specification: http://manuals.dekodi.de/nexuspub/schnittstellenbuch/
@@ -113,7 +115,7 @@ class DekodiNREIExporter(BaseExporter):
'PTNo14': p.info_data.get('reference') or '', 'PTNo14': p.info_data.get('reference') or '',
'PTNo15': p.full_id 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) src = p.info_data.get("source", p.info_data)
payments.append({ payments.append({
'PTID': '81', 'PTID': '81',
@@ -192,17 +194,12 @@ class DekodiNREIExporter(BaseExporter):
def render(self, form_data): def render(self, form_data):
qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent') qs = self.event.invoices.select_related('order').prefetch_related('lines', 'lines__subevent')
if form_data.get('date_from'): if form_data.get('date_range'):
date_value = form_data.get('date_from') d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
if isinstance(date_value, str): if d_start:
date_value = dateutil.parser.parse(date_value).date() qs = qs.filter(date__gte=d_start)
qs = qs.filter(date__gte=date_value) if d_end:
qs = qs.filter(date__lte=d_end)
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)
jo = { jo = {
'Format': 'NREI', 'Format': 'NREI',
@@ -218,22 +215,14 @@ class DekodiNREIExporter(BaseExporter):
def export_form_fields(self): def export_form_fields(self):
return OrderedDict( return OrderedDict(
[ [
('date_from', ('date_range',
forms.DateField( DateFrameField(
label=gettext_lazy('Start date'), label=gettext_lazy('Date range'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}), include_future_frames=False,
required=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.') '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.')
)),
] ]
) )

View File

@@ -35,7 +35,7 @@
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
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 ...control.forms.filter import get_all_payment_providers
from ..exporter import ListExporter from ..exporter import ListExporter
@@ -45,6 +45,8 @@ from ..signals import register_multievent_data_exporters
class EventDataExporter(ListExporter): class EventDataExporter(ListExporter):
identifier = 'eventdata' identifier = 'eventdata'
verbose_name = _('Event data') 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 @cached_property
def providers(self): def providers(self):

View File

@@ -38,13 +38,15 @@ from collections import OrderedDict
from decimal import Decimal from decimal import Decimal
from zipfile import ZipFile from zipfile import ZipFile
import dateutil.parser
from django import forms from django import forms
from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum from django.db.models import CharField, Exists, F, OuterRef, Q, Subquery, Sum
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
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 from pretix.base.models import Invoice, InvoiceLine, OrderPayment
@@ -57,30 +59,24 @@ from ..services.invoices import invoice_pdf_task
from ..signals import ( from ..signals import (
register_data_exporters, register_multievent_data_exporters, register_data_exporters, register_multievent_data_exporters,
) )
from ..timeframes import DateFrameField, resolve_timeframe_to_dates_inclusive
class InvoiceExporterMixin: class InvoiceExporterMixin:
category = pgettext_lazy('export_category', 'Invoices')
@property @property
def invoice_exporter_form_fields(self): def invoice_exporter_form_fields(self):
return OrderedDict( return OrderedDict(
[ [
('date_from', ('date_range',
forms.DateField( DateFrameField(
label=_('Start date'), label=_('Date range'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}), include_future_frames=False,
required=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.') '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', ('payment_provider',
forms.ChoiceField( forms.ChoiceField(
label=_('Payment provider'), label=_('Payment provider'),
@@ -112,16 +108,12 @@ class InvoiceExporterMixin:
) )
) )
qs = qs.filter(has_payment_with_provider=1) qs = qs.filter(has_payment_with_provider=1)
if form_data.get('date_from'): if form_data.get('date_range'):
date_value = form_data.get('date_from') d_start, d_end = resolve_timeframe_to_dates_inclusive(now(), form_data['date_range'], self.timezone)
if isinstance(date_value, str): if d_start:
date_value = dateutil.parser.parse(date_value).date() qs = qs.filter(date__gte=d_start)
qs = qs.filter(date__gte=date_value) if d_end:
if form_data.get('date_to'): qs = qs.filter(date__lte=d_end)
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)
return qs return qs
@@ -129,6 +121,7 @@ class InvoiceExporterMixin:
class InvoiceExporter(InvoiceExporterMixin, BaseExporter): class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
identifier = 'invoices' identifier = 'invoices'
verbose_name = _('All 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): def render(self, form_data: dict, output_file=None):
qs = self.invoices_queryset(form_data).filter(shredded=False) qs = self.invoices_queryset(form_data).filter(shredded=False)
@@ -180,6 +173,10 @@ class InvoiceExporter(InvoiceExporterMixin, BaseExporter):
class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter): class InvoiceDataExporter(InvoiceExporterMixin, MultiSheetListExporter):
identifier = 'invoicedata' identifier = 'invoicedata'
verbose_name = _('Invoice data') 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 @property
def additional_form_fields(self): def additional_form_fields(self):

View File

@@ -22,7 +22,7 @@
from django.db.models import Prefetch from django.db.models import Prefetch
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.formats import date_format 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.styles import Alignment
from openpyxl.utils import get_column_letter from openpyxl.utils import get_column_letter
@@ -48,6 +48,8 @@ def _min(a1, a2):
class ItemDataExporter(ListExporter): class ItemDataExporter(ListExporter):
identifier = 'itemdata' identifier = 'itemdata'
verbose_name = _('Product data') 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): def iterate_list(self, form_data):
locales = self.event.settings.locales locales = self.event.settings.locales
@@ -73,6 +75,7 @@ class ItemDataExporter(ListExporter):
_("Free price input"), _("Free price input"),
_("Sales tax"), _("Sales tax"),
_("Is an admission ticket"), _("Is an admission ticket"),
_("Personalized ticket"),
_("Generate tickets"), _("Generate tickets"),
_("Waiting list"), _("Waiting list"),
_("Available from"), _("Available from"),
@@ -144,6 +147,7 @@ class ItemDataExporter(ListExporter):
_("Yes") if i.free_price else "", _("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "", str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission 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.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
_("Yes") if i.allow_waitinglist else "", _("Yes") if i.allow_waitinglist else "",
date_format(_max(i.available_from, v.available_from).astimezone(self.timezone), date_format(_max(i.available_from, v.available_from).astimezone(self.timezone),
@@ -187,6 +191,7 @@ class ItemDataExporter(ListExporter):
_("Yes") if i.free_price else "", _("Yes") if i.free_price else "",
str(i.tax_rule) if i.tax_rule else "", str(i.tax_rule) if i.tax_rule else "",
_("Yes") if i.admission 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.generate_tickets else (_("Default") if i.generate_tickets is None else ""),
_("Yes") if i.allow_waitinglist else "", _("Yes") if i.allow_waitinglist else "",
date_format(i.available_from.astimezone(self.timezone), date_format(i.available_from.astimezone(self.timezone),

View File

@@ -38,6 +38,8 @@ from decimal import Decimal
from django.core.serializers.json import DjangoJSONEncoder from django.core.serializers.json import DjangoJSONEncoder
from django.db.models import Prefetch from django.db.models import Prefetch
from django.dispatch import receiver 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 ..exporter import BaseExporter
from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue from ..models import ItemMetaValue, ItemVariation, ItemVariationMetaValue
@@ -46,7 +48,10 @@ from ..signals import register_data_exporters
class JSONExporter(BaseExporter): class JSONExporter(BaseExporter):
identifier = 'json' 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): def render(self, form_data):
jo = { jo = {
@@ -78,6 +83,7 @@ class JSONExporter(BaseExporter):
'tax_rate': item.tax_rule.rate if item.tax_rule else Decimal('0.00'), '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, 'tax_name': str(item.tax_rule.name) if item.tax_rule else None,
'admission': item.admission, 'admission': item.admission,
'personalized': item.personalized,
'active': item.active, 'active': item.active,
'sales_channels': item.sales_channels, 'sales_channels': item.sales_channels,
'description': str(item.description), 'description': str(item.description),

View File

@@ -36,7 +36,7 @@ from collections import OrderedDict
from django import forms from django import forms
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _, pgettext_lazy
from pretix.base.models import OrderPosition from pretix.base.models import OrderPosition
@@ -50,6 +50,8 @@ from ..signals import (
class MailExporter(BaseExporter): class MailExporter(BaseExporter):
identifier = 'mailaddrs' identifier = 'mailaddrs'
verbose_name = _('Email addresses (text file)') 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): def render(self, form_data: dict):
qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event') qs = Order.objects.filter(event__in=self.events, status__in=form_data['status']).prefetch_related('event')

View File

@@ -33,10 +33,8 @@
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from collections import OrderedDict from collections import OrderedDict
from datetime import date, datetime, time
from decimal import Decimal from decimal import Decimal
import dateutil
import pytz import pytz
from django import forms from django import forms
from django.db.models import ( from django.db.models import (
@@ -46,8 +44,10 @@ from django.db.models import (
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import get_current_timezone, make_aware, now from django.utils.timezone import get_current_timezone, now
from django.utils.translation import gettext as _, gettext_lazy, pgettext from django.utils.translation import (
gettext as _, gettext_lazy, pgettext, pgettext_lazy,
)
from pretix.base.models import ( from pretix.base.models import (
GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order, GiftCard, GiftCardTransaction, Invoice, InvoiceAddress, Order,
@@ -63,14 +63,24 @@ from ...helpers.iter import chunked_iterable
from ..exporter import ( from ..exporter import (
ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin, ListExporter, MultiSheetListExporter, OrganizerLevelExportMixin,
) )
from ..forms.widgets import SplitDateTimePickerWidget
from ..signals import ( from ..signals import (
register_data_exporters, register_multievent_data_exporters, register_data_exporters, register_multievent_data_exporters,
) )
from ..timeframes import (
DateFrameField,
resolve_timeframe_to_datetime_start_inclusive_end_exclusive,
)
class OrderListExporter(MultiSheetListExporter): class OrderListExporter(MultiSheetListExporter):
identifier = 'orderlist' identifier = 'orderlist'
verbose_name = gettext_lazy('Order data') 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 @cached_property
def providers(self): def providers(self):
@@ -105,41 +115,25 @@ class OrderListExporter(MultiSheetListExporter):
initial=False, initial=False,
required=False required=False
)), )),
('date_from', ('date_range',
forms.DateField( DateFrameField(
label=_('Start date'), label=_('Date range'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}), include_future_frames=False,
required=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', ('event_date_range',
forms.DateField( DateFrameField(
label=_('End date'), label=_('Event date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}), include_future_frames=True,
required=False, required=False,
help_text=_('Only include orders created on or before this date.') help_text=_('Only include orders including at least one ticket for a date in this range. '
)),
('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. '
'Will also include other dates in case of mixed orders!') 'Will also include other dates in case of mixed orders!')
)), )),
] ]
d = OrderedDict(d) d = OrderedDict(d)
if not self.is_multievent and not self.event.has_subevents: if not self.is_multievent and not self.event.has_subevents:
del d['event_date_from'] del d['event_date_range']
del d['event_date_to']
return d return d
def _get_all_payment_methods(self, qs): def _get_all_payment_methods(self, qs):
@@ -182,45 +176,27 @@ class OrderListExporter(MultiSheetListExporter):
annotations = {} annotations = {}
filters = {} filters = {}
if form_data.get('date_from'): if form_data.get('date_range'):
date_value = form_data.get('date_from') dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
if not isinstance(date_value, date): if dt_start:
date_value = dateutil.parser.parse(date_value).date() filters[f'{rel}datetime__gte'] = dt_start
datetime_value = make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone) if dt_end:
filters[f'{rel}datetime__lt'] = dt_end
filters[f'{rel}datetime__gte'] = 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 form_data.get('date_to'): if dt_start:
date_value = form_data.get('date_to') annotations['event_date_max'] = Case(
if not isinstance(date_value, date): When(**{f'{rel}event__has_subevents': True}, then=Max(f'{rel}all_positions__subevent__date_from')),
date_value = dateutil.parser.parse(date_value).date() default=F(f'{rel}event__date_from'),
datetime_value = make_aware(datetime.combine(date_value, time(23, 59, 59, 999999)), self.timezone) )
filters['event_date_max__gte'] = dt_start
filters[f'{rel}datetime__lte'] = datetime_value if dt_end:
annotations['event_date_min'] = Case(
if form_data.get('event_date_from'): When(**{f'{rel}event__has_subevents': True}, then=Min(f'{rel}all_positions__subevent__date_from')),
date_value = form_data.get('event_date_from') default=F(f'{rel}event__date_from'),
if not isinstance(date_value, date): )
date_value = dateutil.parser.parse(date_value).date() filters['event_date_min__lt'] = dt_end
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 filters: if filters:
return qs.annotate(**annotations).filter(**filters) return qs.annotate(**annotations).filter(**filters)
@@ -776,7 +752,10 @@ class OrderListExporter(MultiSheetListExporter):
class PaymentListExporter(ListExporter): class PaymentListExporter(ListExporter):
identifier = 'paymentlist' 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 @property
def additional_form_fields(self): def additional_form_fields(self):
@@ -855,6 +834,8 @@ class PaymentListExporter(ListExporter):
class QuotaListExporter(ListExporter): class QuotaListExporter(ListExporter):
identifier = 'quotalist' identifier = 'quotalist'
verbose_name = gettext_lazy('Quota availabilities') 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): def iterate_list(self, form_data):
has_subevents = self.event.has_subevents has_subevents = self.event.has_subevents
@@ -908,21 +889,17 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardtransactionlist' identifier = 'giftcardtransactionlist'
verbose_name = gettext_lazy('Gift card transactions') verbose_name = gettext_lazy('Gift card transactions')
organizer_required_permission = 'can_manage_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 card transactions.')
@property @property
def additional_form_fields(self): def additional_form_fields(self):
d = [ d = [
('date_from', ('date_range',
forms.DateField( DateFrameField(
label=_('Start date'), label=_('Date range'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}), include_future_frames=False,
required=False, required=False
)),
('date_to',
forms.DateField(
label=_('End date'),
widget=forms.DateInput(attrs={'class': 'datepickerfield'}),
required=False,
)), )),
] ]
d = OrderedDict(d) d = OrderedDict(d)
@@ -933,22 +910,12 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
card__issuer=self.organizer, card__issuer=self.organizer,
).order_by('datetime').select_related('card', 'order', 'order__event') ).order_by('datetime').select_related('card', 'order', 'order__event')
if form_data.get('date_from'): if form_data.get('date_range'):
date_value = form_data.get('date_from') dt_start, dt_end = resolve_timeframe_to_datetime_start_inclusive_end_exclusive(now(), form_data['date_range'], self.timezone)
if isinstance(date_value, str): if dt_start:
date_value = dateutil.parser.parse(date_value).date() qs = qs.filter(datetime__gte=dt_start)
qs = qs.filter( if dt_end:
datetime__gte=make_aware(datetime.combine(date_value, time(0, 0, 0)), self.timezone) qs = qs.filter(datetime__lt=dt_end)
)
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)
)
headers = [ headers = [
_('Gift card code'), _('Gift card code'),
@@ -978,6 +945,8 @@ class GiftcardTransactionListExporter(OrganizerLevelExportMixin, ListExporter):
class GiftcardRedemptionListExporter(ListExporter): class GiftcardRedemptionListExporter(ListExporter):
identifier = 'giftcardredemptionlist' identifier = 'giftcardredemptionlist'
verbose_name = gettext_lazy('Gift card redemptions') 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): def iterate_list(self, form_data):
payments = OrderPayment.objects.filter( payments = OrderPayment.objects.filter(
@@ -1023,14 +992,18 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
identifier = 'giftcardlist' identifier = 'giftcardlist'
verbose_name = gettext_lazy('Gift cards') verbose_name = gettext_lazy('Gift cards')
organizer_required_permission = 'can_manage_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 @property
def additional_form_fields(self): def additional_form_fields(self):
return OrderedDict( return OrderedDict(
[ [
('date', forms.DateTimeField( ('date', forms.SplitDateTimeField(
label=_('Show value at'), label=_('Show value at'),
initial=now(), required=False,
widget=SplitDateTimePickerWidget(),
help_text=_('Defaults to the time of report.')
)), )),
('testmode', forms.ChoiceField( ('testmode', forms.ChoiceField(
label=_('Test mode'), label=_('Test mode'),
@@ -1058,12 +1031,13 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
) )
def iterate_list(self, form_data): def iterate_list(self, form_data):
d = form_data.get('date') or now()
s = GiftCardTransaction.objects.filter( s = GiftCardTransaction.objects.filter(
card=OuterRef('pk'), card=OuterRef('pk'),
datetime__lte=form_data['date'] datetime__lte=d
).order_by().values('card').annotate(s=Sum('value')).values('s') ).order_by().values('card').annotate(s=Sum('value')).values('s')
qs = self.organizer.issued_gift_cards.filter( qs = self.organizer.issued_gift_cards.filter(
issuance__lte=form_data['date'] issuance__lte=d
).annotate( ).annotate(
cached_value=Coalesce(Subquery(s), Decimal('0.00')), cached_value=Coalesce(Subquery(s), Decimal('0.00')),
).order_by('issuance').prefetch_related( ).order_by('issuance').prefetch_related(
@@ -1078,11 +1052,11 @@ class GiftcardListExporter(OrganizerLevelExportMixin, ListExporter):
if form_data.get('state') == 'empty': if form_data.get('state') == 'empty':
qs = qs.filter(cached_value=0) qs = qs.filter(cached_value=0)
elif form_data.get('state') == 'valid_value': 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': 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': elif form_data.get('state') == 'expired':
qs = qs.filter(expires__lt=form_data['date']) qs = qs.filter(expires__lt=d)
headers = [ headers = [
_('Gift card code'), _('Gift card code'),

View File

@@ -39,6 +39,8 @@ from ..signals import (
class WaitingListExporter(ListExporter): class WaitingListExporter(ListExporter):
identifier = 'waitinglist' identifier = 'waitinglist'
verbose_name = _('Waiting list') 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 # map selected status to label and queryset-filter
status_filters = [ status_filters = [

View File

@@ -531,7 +531,7 @@ class PortraitImageField(SizeValidationMixin, ExtValidationMixin, forms.FileFiel
code='aspect_ratio_not_3_by_4', code='aspect_ratio_not_3_by_4',
) )
except Exception as exc: except Exception as exc:
logger.exception('foo') logger.exception('Could not parse image')
# Pillow doesn't recognize it as an image. # Pillow doesn't recognize it as an image.
if isinstance(exc, ValidationError): if isinstance(exc, ValidationError):
raise raise
@@ -575,7 +575,7 @@ class BaseQuestionsForm(forms.Form):
add_fields = {} 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( add_fields['attendee_name_parts'] = NamePartsFormField(
max_length=255, max_length=255,
required=event.settings.attendee_names_required and not self.all_optional, required=event.settings.attendee_names_required and not self.all_optional,
@@ -584,7 +584,7 @@ class BaseQuestionsForm(forms.Form):
label=_('Attendee name'), label=_('Attendee name'),
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts), 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( add_fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required and not self.all_optional, required=event.settings.attendee_emails_required and not self.all_optional,
label=_('Attendee email'), label=_('Attendee email'),
@@ -595,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( add_fields['company'] = forms.CharField(
required=event.settings.attendee_company_required and not self.all_optional, required=event.settings.attendee_company_required and not self.all_optional,
label=_('Company'), label=_('Company'),
@@ -603,7 +603,7 @@ class BaseQuestionsForm(forms.Form):
initial=(cartpos.company if cartpos else orderpos.company), 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( add_fields['street'] = forms.CharField(
required=event.settings.attendee_addresses_required and not self.all_optional, required=event.settings.attendee_addresses_required and not self.all_optional,
label=_('Address'), label=_('Address'),

View File

@@ -30,7 +30,6 @@ from django.urls import get_script_prefix
from django.utils import timezone, translation from django.utils import timezone, translation
from django.utils.cache import patch_vary_headers from django.utils.cache import patch_vary_headers
from django.utils.deprecation import MiddlewareMixin from django.utils.deprecation import MiddlewareMixin
from django.utils.translation import LANGUAGE_SESSION_KEY
from django.utils.translation.trans_real import ( from django.utils.translation.trans_real import (
check_for_language, get_supported_language_variant, language_code_re, check_for_language, get_supported_language_variant, language_code_re,
parse_accept_lang_header, parse_accept_lang_header,
@@ -128,12 +127,7 @@ def get_language_from_user_settings(request: HttpRequest) -> str:
return lang_code return lang_code
def get_language_from_session_or_cookie(request: HttpRequest) -> str: def get_language_from_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
lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME) lang_code = request.COOKIES.get(settings.LANGUAGE_COOKIE_NAME)
try: try:
return get_supported_language_variant(lang_code) return get_supported_language_variant(lang_code)
@@ -187,14 +181,14 @@ def get_language_from_request(request: HttpRequest) -> str:
return ( return (
get_language_from_user_settings(request) get_language_from_user_settings(request)
or get_language_from_customer_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_browser(request)
or get_language_from_event(request) or get_language_from_event(request)
or get_default_language() or get_default_language()
) )
else: else:
return ( return (
get_language_from_session_or_cookie(request) get_language_from_cookie(request)
or get_language_from_customer_settings(request) or get_language_from_customer_settings(request)
or get_language_from_user_settings(request) or get_language_from_user_settings(request)
or get_language_from_browser(request) or get_language_from_browser(request)

View File

@@ -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,
),
]

View File

@@ -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),
),
]

View File

@@ -30,6 +30,7 @@ from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue, Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
SubEvent, SubEventMetaValue, generate_invite_token, SubEvent, SubEventMetaValue, generate_invite_token,
) )
from .exports import ScheduledEventExport, ScheduledOrganizerExport
from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction from .giftcards import GiftCard, GiftCardAcceptance, GiftCardTransaction
from .invoices import Invoice, InvoiceLine, invoice_filename from .invoices import Invoice, InvoiceLine, invoice_filename
from .items import ( from .items import (

View File

@@ -36,13 +36,17 @@ from datetime import timedelta
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import models from django.db import models
from django.db.models import Exists, F, Max, OuterRef, Q, Subquery from django.db.models import (
Count, Exists, F, Max, OuterRef, Q, Subquery, Value, Window,
)
from django.db.models.expressions import RawSQL
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager, scopes_disabled from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.helpers import PostgresWindowFrame
class CheckinList(LoggedModel): class CheckinList(LoggedModel):
@@ -95,15 +99,18 @@ class CheckinList(LoggedModel):
class Meta: class Meta:
ordering = ('subevent__date_from', 'name') ordering = ('subevent__date_from', 'name')
@property def positions_query(self, ignore_status=False):
def positions(self):
from . import Order, OrderPosition from . import Order, OrderPosition
qs = OrderPosition.objects.filter( qs = OrderPosition.all.filter(
order__event=self.event, order__event=self.event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [
Order.STATUS_PAID],
) )
if not ignore_status:
qs = qs.filter(
canceled=False,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID],
)
if self.subevent_id: if self.subevent_id:
qs = qs.filter(subevent_id=self.subevent_id) qs = qs.filter(subevent_id=self.subevent_id)
if not self.all_products: if not self.all_products:
@@ -111,36 +118,90 @@ class CheckinList(LoggedModel):
return qs return qs
@property @property
def positions_inside(self): def positions(self):
return self.positions.annotate( return self.positions_query(ignore_status=False)
last_entry=Subquery(
Checkin.objects.filter( @scopes_disabled()
position_id=OuterRef('pk'), def positions_inside_query(self, ignore_status=False, at_time=None):
list_id=self.pk, if at_time is None:
type=Checkin.TYPE_ENTRY, c_q = []
).order_by().values('position_id').annotate( else:
m=Max('datetime') c_q = [Q(datetime__lt=at_time)]
).values('m')
), if "postgresql" not in settings.DATABASES["default"]["ENGINE"]:
last_exit=Subquery( # Use a simple approach that works on all databases
Checkin.objects.filter( qs = self.positions_query(ignore_status=ignore_status).annotate(
position_id=OuterRef('pk'), last_entry=Subquery(
list_id=self.pk, Checkin.objects.filter(
type=Checkin.TYPE_EXIT, *c_q,
).order_by().values('position_id').annotate( position_id=OuterRef('pk'),
m=Max('datetime') list_id=self.pk,
).values('m') type=Checkin.TYPE_ENTRY,
), ).order_by().values('position_id').annotate(
).filter( m=Max('datetime')
Q(last_entry__isnull=False) ).values('m')
& Q( ),
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry')) last_exit=Subquery(
Checkin.objects.filter(
*c_q,
position_id=OuterRef('pk'),
list_id=self.pk,
type=Checkin.TYPE_EXIT,
).order_by().values('position_id').annotate(
m=Max('datetime')
).values('m')
),
).filter(
Q(last_entry__isnull=False)
& Q(
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
)
)
return qs
# 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(*c_q, successful=True, 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()
)
return self.positions_query(ignore_status=ignore_status).filter(
pk__in=RawSQL(
f"""
SELECT "position_id"
FROM ({str(base_q)}) s
WHERE "type" = %s AND "cnt_exists_after" = 0
GROUP BY "position_id"
""",
[*base_params, Checkin.TYPE_ENTRY]
) )
) )
@property
def positions_inside(self):
return self.positions_inside_query(None)
@property @property
def inside_count(self): def inside_count(self):
return self.positions_inside.count() return self.positions_inside_query(None).count()
@property @property
@scopes_disabled() @scopes_disabled()

View File

@@ -374,7 +374,7 @@ class EventMixin:
if q.active_items: if q.active_items:
items_reserved.update(q.active_items.split(",")) items_reserved.update(q.active_items.split(","))
if q.active_variations: if q.active_variations:
vars_available.update(q.active_variations.split(",")) vars_reserved.update(q.active_variations.split(","))
elif res[0] < Quota.AVAILABILITY_RESERVED: elif res[0] < Quota.AVAILABILITY_RESERVED:
if q.active_items: if q.active_items:
items_gone.update(q.active_items.split(",")) items_gone.update(q.active_items.split(","))
@@ -632,6 +632,7 @@ class Event(EventMixin, LoggedModel):
return super().presale_has_ended return super().presale_has_ended
def delete_all_orders(self, really=False): def delete_all_orders(self, really=False):
from .checkin import Checkin
from .orders import ( from .orders import (
OrderFee, OrderPayment, OrderPosition, OrderRefund, Transaction, OrderFee, OrderPayment, OrderPosition, OrderRefund, Transaction,
) )
@@ -645,6 +646,7 @@ class Event(EventMixin, LoggedModel):
OrderFee.objects.filter(order__event=self).delete() OrderFee.objects.filter(order__event=self).delete()
OrderRefund.objects.filter(order__event=self).delete() OrderRefund.objects.filter(order__event=self).delete()
OrderPayment.objects.filter(order__event=self).delete() OrderPayment.objects.filter(order__event=self).delete()
Checkin.objects.filter(list__event=self).delete()
self.orders.all().delete() self.orders.all().delete()
def save(self, *args, **kwargs): def save(self, *args, **kwargs):

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)

View File

@@ -62,6 +62,7 @@ from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice from pretix.base.models.tax import TaxedPrice
from ...helpers.images import ImageSizeValidator
from .event import Event, SubEvent from .event import Event, SubEvent
@@ -310,6 +311,8 @@ class Item(LoggedModel):
:type tax_rate: decimal.Decimal :type tax_rate: decimal.Decimal
:param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise) :param admission: ``True``, if this item allows persons to enter the event (as opposed to e.g. merchandise)
:type admission: bool :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 :param picture: A product picture to be shown next to the product description
:type picture: File :type picture: File
:param available_from: The date this product goes on sale :param available_from: The date this product goes on sale
@@ -396,8 +399,14 @@ class Item(LoggedModel):
admission = models.BooleanField( admission = models.BooleanField(
verbose_name=_("Is an admission ticket"), verbose_name=_("Is an admission ticket"),
help_text=_( help_text=_(
'Whether or not buying this product allows a person to enter ' 'Whether or not buying this product allows a person to enter your event'
'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 default=False
) )
@@ -421,7 +430,8 @@ class Item(LoggedModel):
picture = models.ImageField( picture = models.ImageField(
verbose_name=_("Product picture"), verbose_name=_("Product picture"),
null=True, blank=True, max_length=255, null=True, blank=True, max_length=255,
upload_to=itempicture_upload_to upload_to=itempicture_upload_to,
validators=[ImageSizeValidator()]
) )
available_from = models.DateTimeField( available_from = models.DateTimeField(
verbose_name=_("Available from"), verbose_name=_("Available from"),
@@ -578,6 +588,10 @@ class Item(LoggedModel):
return self.event.settings.show_quota_left return self.event.settings.show_quota_left
return self.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): 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 price = price if price is not None else self.default_price

View File

@@ -79,6 +79,7 @@ from pretix.base.services.locking import LOCK_TIMEOUT, NoLockManager
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import order_gracefully_delete from pretix.base.signals import order_gracefully_delete
from ...helpers import OF_SELF
from ...helpers.countries import CachedCountries, FastCountryField from ...helpers.countries import CachedCountries, FastCountryField
from ...helpers.format import format_map from ...helpers.format import format_map
from ._transactions import ( from ._transactions import (
@@ -808,7 +809,7 @@ class Order(LockModel, LoggedModel):
return True return True
ask_names = self.event.settings.get('attendee_names_asked', as_type=bool) ask_names = self.event.settings.get('attendee_names_asked', as_type=bool)
for cp in positions: 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 True
return False # nothing there to modify return False # nothing there to modify
@@ -1628,7 +1629,7 @@ class OrderPayment(models.Model):
been marked as paid. been marked as paid.
""" """
with transaction.atomic(): with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk) locked_instance = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING): if locked_instance.state not in (OrderPayment.PAYMENT_STATE_CREATED, OrderPayment.PAYMENT_STATE_PENDING):
# Race condition detected, this payment is already confirmed # Race condition detected, this payment is already confirmed
logger.info('Failed payment {} but ignored due to likely race condition.'.format( logger.info('Failed payment {} but ignored due to likely race condition.'.format(
@@ -1673,7 +1674,7 @@ class OrderPayment(models.Model):
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
""" """
with transaction.atomic(): with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk) locked_instance = OrderPayment.objects.select_for_update(of=OF_SELF).get(pk=self.pk)
if locked_instance.state == self.PAYMENT_STATE_CONFIRMED: if locked_instance.state == self.PAYMENT_STATE_CONFIRMED:
# Race condition detected, this payment is already confirmed # Race condition detected, this payment is already confirmed
logger.info('Confirmed payment {} but ignored due to likely race condition.'.format( logger.info('Confirmed payment {} but ignored due to likely race condition.'.format(
@@ -2688,7 +2689,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) 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 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) 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 sort_key = subevent_key + category_key + item_key + variation_key + line_key
if self.addon_to_id: if self.addon_to_id:

View File

@@ -23,6 +23,7 @@ import json
from decimal import Decimal from decimal import Decimal
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.core.validators import MaxValueValidator, MinValueValidator
from django.db import models from django.db import models
from django.utils.formats import localize from django.utils.formats import localize
from django.utils.translation import gettext_lazy as _, pgettext from django.utils.translation import gettext_lazy as _, pgettext
@@ -149,7 +150,15 @@ class TaxRule(LoggedModel):
rate = models.DecimalField( rate = models.DecimalField(
max_digits=10, max_digits=10,
decimal_places=2, 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( price_includes_tax = models.BooleanField(
verbose_name=_("The configured product prices include the tax amount"), verbose_name=_("The configured product prices include the tax amount"),

View File

@@ -20,6 +20,7 @@
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from datetime import timedelta from datetime import timedelta
from typing import Any, Dict, Union
from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db import models, transaction 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.timezone import now
from django.utils.translation import gettext_lazy as _, pgettext_lazy from django.utils.translation import gettext_lazy as _, pgettext_lazy
from django_scopes import ScopedManager from django_scopes import ScopedManager
from i18nfield.strings import LazyI18nString
from phonenumber_field.modelfields import PhoneNumberField from phonenumber_field.modelfields import PhoneNumberField
from pretix.base.email import get_email_context from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Voucher from pretix.base.models import User, Voucher
from pretix.base.services.mail import mail from pretix.base.services.mail import SendMailException, mail, render_mail
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from ...helpers.format import format_map
from .base import LoggedModel from .base import LoggedModel
from .event import Event, SubEvent from .event import Event, SubEvent
from .items import Item, ItemVariation from .items import Item, ItemVariation
@@ -213,15 +216,74 @@ class WaitingListEntry(LoggedModel):
self.voucher = v self.voucher = v
self.save() 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): with language(self.locale, self.event.settings.region):
mail( recipient = self.email
self.email,
self.event.settings.mail_subject_waiting_list, try:
self.event.settings.mail_text_waiting_list, email_content = render_mail(template, context)
get_email_context(event=self.event, waiting_list_entry=self), subject = format_map(subject, context)
self.event, mail(
locale=self.locale 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 @staticmethod
def clean_itemvar(event, item, variation): def clean_itemvar(event, item, variation):

View File

@@ -67,6 +67,7 @@ from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers import OF_SELF
from pretix.helpers.countries import CachedCountries from pretix.helpers.countries import CachedCountries
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
from pretix.helpers.money import DecimalTextInput from pretix.helpers.money import DecimalTextInput
@@ -1399,7 +1400,7 @@ class GiftCardPayment(BasePaymentProvider):
try: try:
with transaction.atomic(): with transaction.atomic():
try: try:
gc = GiftCard.objects.select_for_update().get(pk=gcpk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gcpk)
except GiftCard.DoesNotExist: except GiftCard.DoesNotExist:
raise PaymentException(_("This gift card does not support this currency.")) raise PaymentException(_("This gift card does not support this currency."))
if gc.currency != self.event.currency: # noqa - just a safeguard if gc.currency != self.event.currency: # noqa - just a safeguard

View File

@@ -35,6 +35,7 @@
import copy import copy
import hashlib import hashlib
import itertools import itertools
import json
import logging import logging
import os import os
import re import re
@@ -46,12 +47,15 @@ from collections import OrderedDict
from functools import partial from functools import partial
from io import BytesIO from io import BytesIO
import jsonschema
from arabic_reshaper import ArabicReshaper from arabic_reshaper import ArabicReshaper
from bidi.algorithm import get_display from bidi.algorithm import get_display
from django.conf import settings from django.conf import settings
from django.contrib.staticfiles import finders from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db.models import Max, Min from django.db.models import Max, Min
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.deconstruct import deconstructible
from django.utils.formats import date_format from django.utils.formats import date_format
from django.utils.functional import SimpleLazyObject from django.utils.functional import SimpleLazyObject
from django.utils.html import conditional_escape from django.utils.html import conditional_escape
@@ -740,9 +744,9 @@ class Renderer:
if o['content'] == 'other' or o['content'] == 'other_i18n': if o['content'] == 'other' or o['content'] == 'other_i18n':
if o['content'] == 'other_i18n': if o['content'] == 'other_i18n':
text = str(LazyI18nString(o['text_i18n'])) text = str(LazyI18nString(o.get('text_i18n', {})))
else: else:
text = o['text'] text = o.get('text', '')
def replace(x): def replace(x):
if x.group(1).startswith('itemmeta:'): if x.group(1).startswith('itemmeta:'):
@@ -975,3 +979,22 @@ class Renderer:
output.write(outbuffer) output.write(outbuffer)
outbuffer.seek(0) outbuffer.seek(0)
return outbuffer 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))

View File

@@ -59,10 +59,10 @@ class RelativeDateWrapper:
def date(self, event) -> datetime.date: def date(self, event) -> datetime.date:
from .models import SubEvent from .models import SubEvent
if isinstance(self.data, datetime.date): if isinstance(self.data, datetime.datetime):
return self.data
elif isinstance(self.data, datetime.datetime):
return self.data.date() return self.data.date()
elif isinstance(self.data, datetime.date):
return self.data
else: else:
if self.data.minutes_before is not None: if self.data.minutes_before is not None:
raise ValueError('A minute-based relative datetime can not be used as a date') raise ValueError('A minute-based relative datetime can not be used as a date')

View File

@@ -41,6 +41,7 @@ from pretix.base.services.orders import (
) )
from pretix.base.services.tasks import ProfiledEventTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.format import format_map from pretix.helpers.format import format_map
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -239,7 +240,7 @@ def cancel_event(self, event: Event, subevent: int, auto_refund: bool,
for o in orders_to_change.values_list('id', flat=True).iterator(): for o in orders_to_change.values_list('id', flat=True).iterator():
with transaction.atomic(): with transaction.atomic():
o = event.orders.select_for_update().get(pk=o) o = event.orders.select_for_update(of=OF_SELF).get(pk=o)
total = Decimal('0.00') total = Decimal('0.00')
fee = Decimal('0.00') fee = Decimal('0.00')
positions = [] positions = []

View File

@@ -56,6 +56,7 @@ from pretix.base.models import (
Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption, Checkin, CheckinList, Device, Order, OrderPosition, QuestionOption,
) )
from pretix.base.signals import checkin_created, order_placed, periodic_task from pretix.base.signals import checkin_created, order_placed, periodic_task
from pretix.helpers import OF_SELF
from pretix.helpers.jsonlogic import Logic from pretix.helpers.jsonlogic import Logic
from pretix.helpers.jsonlogic_boolalg import convert_to_dnf from pretix.helpers.jsonlogic_boolalg import convert_to_dnf
from pretix.helpers.jsonlogic_query import ( from pretix.helpers.jsonlogic_query import (
@@ -289,6 +290,11 @@ def _logic_explain(rules, ev, rule_data):
p for i, p in enumerate(paths) if path_weights[i] == min_weight p for i, p in enumerate(paths) if path_weights[i] == min_weight
] ]
# Step 7: All things equal, prefer shorter explanations
paths_with_min_weight.sort(
key=lambda p: len([v for v in p if not _var_values[v]])
)
# Finally, return the text for one of them # Finally, return the text for one of them
return ', '.join(var_texts[v] for v in paths_with_min_weight[0] if not _var_values[v]) return ', '.join(var_texts[v] for v in paths_with_min_weight[0] if not _var_values[v])
@@ -729,8 +735,11 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
_save_answers(op, answers, given_answers) _save_answers(op, answers, given_answers)
with transaction.atomic(): with transaction.atomic():
# Lock order positions # Lock order positions, if it is an entry. We don't need it for exits, as a race condition wouldn't be problematic
op = OrderPosition.all.select_for_update().get(pk=op.pk) opqs = OrderPosition.all
if type != Checkin.TYPE_EXIT:
opqs = opqs.select_for_update(of=OF_SELF)
op = opqs.get(pk=op.pk)
if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]: if not clist.all_products and op.item_id not in [i.pk for i in clist.limit_products.all()]:
raise CheckInError( raise CheckInError(
@@ -842,10 +851,7 @@ def process_exit_all(sender, **kwargs):
exit_all_at__isnull=False exit_all_at__isnull=False
).select_related('event', 'event__organizer') ).select_related('event', 'event__organizer')
for cl in qs: for cl in qs:
positions = cl.positions_inside.filter( positions = cl.positions_inside_query(ignore_status=True, at_time=cl.exit_all_at)
Q(last_exit__isnull=True) | Q(last_exit__lte=cl.exit_all_at),
last_entry__lte=cl.exit_all_at,
)
for p in positions: for p in positions:
with scope(organizer=cl.event.organizer): with scope(organizer=cl.event.organizer):
ci = Checkin.objects.create( ci = Checkin.objects.create(

View File

@@ -19,31 +19,50 @@
# You should have received a copy of the GNU Affero General Public License along with this program. If not, see # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from 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.conf import settings
from django.core.files.base import ContentFile 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.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.exporter import OrganizerLevelExportMixin
from pretix.base.i18n import LazyLocaleException, language from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import ( 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 ( from pretix.base.services.tasks import (
ProfiledEventTask, ProfiledOrganizerUserTask, EventTask, OrganizerTask, ProfiledEventTask, ProfiledOrganizerUserTask,
) )
from pretix.base.signals import ( 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.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.urls import build_absolute_uri
logger = logging.getLogger(__name__)
class ExportError(LazyLocaleException): class ExportError(LazyLocaleException):
pass pass
class ExportEmptyError(ExportError):
pass
@app.task(base=ProfiledEventTask, throws=(ExportError,), bind=True) @app.task(base=ProfiledEventTask, throws=(ExportError,), bind=True)
def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
def set_progress(val): def set_progress(val):
@@ -56,7 +75,7 @@ def export(self, event: Event, fileid: str, provider: str, form_data: Dict[str,
file = CachedFile.objects.get(id=fileid) file = CachedFile.objects.get(id=fileid)
with language(event.settings.locale, event.settings.region), override(event.settings.timezone): with language(event.settings.locale, event.settings.region), override(event.settings.timezone):
responses = register_data_exporters.send(event) responses = register_data_exporters.send(event)
for receiver, response in responses: for recv, response in responses:
if not response: if not response:
continue continue
ex = response(event, event.organizer, set_progress) ex = response(event, event.organizer, set_progress)
@@ -106,16 +125,16 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
timezone = organizer.settings.timezone or settings.TIME_ZONE timezone = organizer.settings.timezone or settings.TIME_ZONE
region = organizer.settings.region region = organizer.settings.region
with language(locale, region), override(timezone): 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): if isinstance(form_data['events'][0], str):
events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer) events = allowed_events.filter(slug__in=form_data.get('events'), organizer=organizer)
else: else:
events = allowed_events.filter(pk__in=form_data.get('events')) events = allowed_events.filter(pk__in=form_data.get('events'), organizer=organizer)
else: else:
events = allowed_events events = allowed_events.filter(organizer=organizer)
responses = register_multievent_data_exporters.send(organizer) responses = register_multievent_data_exporters.send(organizer)
for receiver, response in responses: for recv, response in responses:
if not response: if not response:
continue continue
ex = response(events, organizer, set_progress) ex = response(events, organizer, set_progress)
@@ -138,3 +157,210 @@ def multiexport(self, organizer: Organizer, user: User, device: int, token: int,
f = ContentFile(data) f = ContentFile(data)
file.file.save(cachedfile_name(file, file.filename), f) file.file.save(cachedfile_name(file, file.filename), f)
return file.pk 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, of=OF_SELF).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, of=OF_SELF).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'])

View File

@@ -63,7 +63,7 @@ from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.settings import GlobalSettingsObject from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import invoice_line_text, periodic_task from pretix.base.signals import invoice_line_text, periodic_task
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction from pretix.helpers.database import OF_SELF, rolledback_transaction
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -500,7 +500,7 @@ def send_invoices_to_organizer(sender, **kwargs):
with transaction.atomic(): with transaction.atomic():
qs = Invoice.objects.filter( qs = Invoice.objects.filter(
sent_to_organizer__isnull=True sent_to_organizer__isnull=True
).prefetch_related('event').select_for_update(skip_locked=connection.features.has_select_for_update_skip_locked) ).prefetch_related('event').select_for_update(of=OF_SELF, skip_locked=connection.features.has_select_for_update_skip_locked)
for i in qs[:batch_size]: for i in qs[:batch_size]:
if i.event.settings.invoice_email_organizer: if i.event.settings.invoice_email_organizer:
with language(i.event.settings.locale): with language(i.event.settings.locale):

View File

@@ -100,7 +100,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
position: OrderPosition = None, *, headers: dict = None, sender: str = None, organizer: Organizer = 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, 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): 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. Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -211,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] subject = raw_subject = str(subject).replace('\n', ' ').replace('\r', '')[:900]
signature = "" signature = ""
bcc = [] bcc = list(bcc or [])
settings_holder = event or organizer settings_holder = event or organizer
@@ -305,6 +305,7 @@ def mail(email: Union[str, Sequence[str]], subject: str, template: Union[str, La
send_task = mail_send_task.si( send_task = mail_send_task.si(
to=[email] if isinstance(email, str) else list(email), to=[email] if isinstance(email, str) else list(email),
cc=cc,
bcc=bcc, bcc=bcc,
subject=subject, subject=subject,
body=body_plain, body=body_plain,
@@ -357,11 +358,11 @@ class CustomEmail(EmailMultiAlternatives):
@app.task(base=TransactionAwareTask, bind=True, acks_late=True) @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, 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, 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, organizer=None, customer=None, attach_ical=False, attach_cached_files: List[int] = None,
attach_other_files: List[str] = None) -> bool: 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: if html is not None:
html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET) html_message = SafeMIMEMultipart(_subtype='related', encoding=settings.DEFAULT_CHARSET)
html_with_cid, cid_images = replace_images_with_cid_paths(html) html_with_cid, cid_images = replace_images_with_cid_paths(html)

View File

@@ -32,6 +32,7 @@ from pretix.base.models import (
AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition, AbstractPosition, Customer, Event, Item, Membership, Order, OrderPosition,
SubEvent, SubEvent,
) )
from pretix.helpers import OF_SELF
def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event): def membership_validity(item: Item, subevent: Optional[SubEvent], event: Event):
@@ -118,7 +119,7 @@ def validate_memberships_in_order(customer: Customer, positions: List[AbstractPo
base_qs = Membership.objects.with_usages(ignored_order=ignored_order) base_qs = Membership.objects.with_usages(ignored_order=ignored_order)
if lock: if lock:
base_qs = base_qs.select_for_update() base_qs = base_qs.select_for_update(of=OF_SELF)
membership_cache = base_qs\ membership_cache = base_qs\
.select_related('membership_type')\ .select_related('membership_type')\

View File

@@ -97,6 +97,7 @@ from pretix.base.signals import (
order_placed, order_split, periodic_task, validate_order, order_placed, order_split, periodic_task, validate_order,
) )
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers import OF_SELF
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
from pretix.helpers.periodic import minimum_interval from pretix.helpers.periodic import minimum_interval
@@ -184,7 +185,7 @@ def reactivate_order(order: Order, force: bool=False, user: User=None, auth=None
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') + 1)) Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') + 1))
for gc in position.issued_gift_cards.all(): for gc in position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update().get(pk=gc.pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
gc.transactions.create(value=position.price, order=order) gc.transactions.create(value=position.price, order=order)
break break
@@ -397,7 +398,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
# If new actions are added to this function, make sure to add the reverse operation to reactivate_order() # If new actions are added to this function, make sure to add the reverse operation to reactivate_order()
with transaction.atomic(): with transaction.atomic():
if isinstance(order, int): if isinstance(order, int):
order = Order.objects.select_for_update().get(pk=order) order = Order.objects.select_for_update(of=OF_SELF).get(pk=order)
if isinstance(user, int): if isinstance(user, int):
user = User.objects.get(pk=user) user = User.objects.get(pk=user)
if isinstance(api_token, int): if isinstance(api_token, int):
@@ -419,7 +420,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
for position in order.positions.all(): for position in order.positions.all():
for gc in position.issued_gift_cards.all(): for gc in position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update().get(pk=gc.pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
if gc.value < position.price: if gc.value < position.price:
raise OrderError( raise OrderError(
_('This order can not be canceled since the gift card {card} purchased in ' _('This order can not be canceled since the gift card {card} purchased in '
@@ -1068,6 +1069,8 @@ def _perform_order(event: Event, payment_requests: List[dict], position_ids: Lis
any_payment_failed = True any_payment_failed = True
except Exception: except Exception:
logger.exception('Error during payment attempt') logger.exception('Error during payment attempt')
else:
order.refresh_from_db()
pending_sum = order.pending_sum pending_sum = order.pending_sum
free_order_flow = ( free_order_flow = (
@@ -1203,7 +1206,7 @@ def send_expiry_warnings(sender, **kwargs):
if days and (o.expires - today).days <= days: if days and (o.expires - today).days <= days:
with transaction.atomic(): with transaction.atomic():
o = Order.objects.select_related('event').select_for_update().get(pk=o.pk) o = Order.objects.select_related('event').select_for_update(of=OF_SELF).get(pk=o.pk)
if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent: if o.status != Order.STATUS_PENDING or o.expiry_reminder_sent:
# Race condition # Race condition
continue continue
@@ -1262,7 +1265,7 @@ def send_download_reminders(sender, **kwargs):
continue continue
with transaction.atomic(): with transaction.atomic():
o = Order.objects.select_for_update().get(pk=o.pk) o = Order.objects.select_for_update(of=OF_SELF).get(pk=o.pk)
if o.download_reminder_sent: if o.download_reminder_sent:
# Race condition # Race condition
continue continue
@@ -2057,7 +2060,7 @@ class OrderChangeManager:
op.fee.save(update_fields=['canceled']) op.fee.save(update_fields=['canceled'])
elif isinstance(op, self.CancelOperation): elif isinstance(op, self.CancelOperation):
for gc in op.position.issued_gift_cards.all(): for gc in op.position.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update().get(pk=gc.pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
if gc.value < op.position.price: if gc.value < op.position.price:
raise OrderError(_( raise OrderError(_(
'A position can not be canceled since the gift card {card} purchased in this order has ' 'A position can not be canceled since the gift card {card} purchased in this order has '
@@ -2073,7 +2076,7 @@ class OrderChangeManager:
for opa in op.position.addons.all(): for opa in op.position.addons.all():
for gc in opa.issued_gift_cards.all(): for gc in opa.issued_gift_cards.all():
gc = GiftCard.objects.select_for_update().get(pk=gc.pk) gc = GiftCard.objects.select_for_update(of=OF_SELF).get(pk=gc.pk)
if gc.value < opa.position.price: if gc.value < opa.position.price:
raise OrderError(_( raise OrderError(_(
'A position can not be canceled since the gift card {card} purchased in this order has ' 'A position can not be canceled since the gift card {card} purchased in this order has '
@@ -2646,9 +2649,9 @@ def change_payment_provider(order: Order, payment_provider, amount=None, new_pay
open_payment = None open_payment = None
if new_payment: if new_payment:
lp = order.payments.select_for_update().exclude(pk=new_payment.pk).last() lp = order.payments.select_for_update(of=OF_SELF).exclude(pk=new_payment.pk).last()
else: else:
lp = order.payments.select_for_update().last() lp = order.payments.select_for_update(of=OF_SELF).last()
if lp and lp.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED): if lp and lp.state in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
open_payment = lp open_payment = lp

View File

@@ -109,6 +109,31 @@ class EventTask(app.Task):
return ret 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): class OrganizerUserTask(app.Task):
def __call__(self, *args, **kwargs): def __call__(self, *args, **kwargs):
organizer_id = kwargs['organizer'] organizer_id = kwargs['organizer']

View File

@@ -206,7 +206,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Ask for attendee names"), 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': { 'attendee_names_required': {
@@ -229,10 +229,10 @@ DEFAULTS = {
label=_("Ask for email addresses per ticket"), 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 " 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 " "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, " "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 " "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': { 'attendee_emails_required': {
@@ -242,7 +242,7 @@ DEFAULTS = {
'serializer_class': serializers.BooleanField, 'serializer_class': serializers.BooleanField,
'form_kwargs': dict( 'form_kwargs': dict(
label=_("Require email addresses per ticket"), 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 " "above option for more details. One email address for the order confirmation will always be "
"required regardless of this setting."), "required regardless of this setting."),
widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}), widget=forms.CheckboxInput(attrs={'data-checkbox-dependency': '#id_settings-attendee_emails_asked'}),
@@ -2574,7 +2574,7 @@ Your {organizer} team"""))
label=_("Attendee data explanation"), label=_("Attendee data explanation"),
widget=I18nTextarea, widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}, 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.") "why you need information from them.")
) )
}, },

View File

@@ -218,8 +218,10 @@ class EmailAddressShredder(BaseDataShredder):
o.meta_info = json.dumps(d) o.meta_info = json.dumps(d)
o.save(update_fields=['meta_info', 'email', 'customer']) o.save(update_fields=['meta_info', 'email', 'customer'])
for le in self.event.logentry_set.filter(action_type__contains="order.email"): for le in self.event.logentry_set.filter(
shred_log_fields(le, banlist=['recipient', 'message', 'subject']) 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"): for le in self.event.logentry_set.filter(action_type="pretix.event.order.contact.changed"):
shred_log_fields(le, banlist=['old_email', 'new_email']) shred_log_fields(le, banlist=['old_email', 'new_email'])

View File

@@ -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 }}

View File

@@ -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 %}

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

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 # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from 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 # 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>. # 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 # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
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: class BanlistValidator:
@@ -101,3 +102,18 @@ class EmailBanlistValidator(BanlistValidator):
banlist = [ banlist = [
settings.PRETIX_EMAIL_NONE_VALUE, 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.")

View File

@@ -78,7 +78,7 @@ class BaseQuestionsViewMixin:
form.pos = cartpos or orderpos form.pos = cartpos or orderpos
form.show_copy_answers_to_addon_button = form.pos.addon_to and ( 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 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_names_asked or
self.request.event.settings.attendee_emails_asked or self.request.event.settings.attendee_emails_asked or
self.request.event.settings.attendee_company_asked or self.request.event.settings.attendee_company_asked or

View File

@@ -28,7 +28,7 @@ from celery import states
from celery.result import AsyncResult from celery.result import AsyncResult
from django.conf import settings from django.conf import settings
from django.contrib import messages 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.http import HttpResponse, JsonResponse, QueryDict
from django.shortcuts import redirect, render from django.shortcuts import redirect, render
from django.test import RequestFactory from django.test import RequestFactory
@@ -149,6 +149,8 @@ class AsyncMixin:
return redirect(self.get_success_url(value)) return redirect(self.get_success_url(value))
def error(self, exception): def error(self, exception):
if isinstance(exception, PermissionDenied):
raise exception
messages.error(self.request, self.get_error_message(exception)) messages.error(self.request, self.get_error_message(exception))
if "ajax" in self.request.POST or "ajax" in self.request.GET: if "ajax" in self.request.POST or "ajax" in self.request.GET:
return JsonResponse({ 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 depend on the request object unless specifically supported by this class. File upload is currently also
not supported. not supported.
""" """
known_errortypes = ['ValidationError'] known_errortypes = ['ValidationError', 'PermissionDenied']
expected_exceptions = (ValidationError,) expected_exceptions = (ValidationError, PermissionDenied)
task_base = ProfiledEventTask task_base = ProfiledEventTask
def async_set_progress(self, percentage): def async_set_progress(self, percentage):

View File

@@ -51,6 +51,9 @@ from django_scopes.forms import SafeModelMultipleChoiceField
from pretix.helpers.hierarkey import clean_filename from pretix.helpers.hierarkey import clean_filename
from ...base.forms import I18nModelForm 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 # Import for backwards compatibility with okd import paths
from ...base.forms.widgets import ( # noqa from ...base.forms.widgets import ( # noqa
@@ -214,12 +217,16 @@ class ExtValidationMixin:
def clean(self, *args, **kwargs): def clean(self, *args, **kwargs):
data = super().clean(*args, **kwargs) data = super().clean(*args, **kwargs)
if isinstance(data, File): if isinstance(data, UploadedFile):
filename = data.name filename = data.name
ext = os.path.splitext(filename)[1] ext = os.path.splitext(filename)[1]
ext = ext.lower() ext = ext.lower()
if ext not in self.ext_whitelist: if ext not in self.ext_whitelist:
raise forms.ValidationError(_("Filetype not allowed!")) raise forms.ValidationError(_("Filetype not allowed!"))
if ext in IMAGE_EXTS:
validate_uploaded_file_for_valid_image(data)
return data return data

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 # distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations under the License. # License for the specific language governing permissions and limitations under the License.
from decimal import Decimal
from urllib.parse import urlencode, urlparse from urllib.parse import urlencode, urlparse
from django import forms from django import forms
from django.conf import settings from django.conf import settings
from django.core.exceptions import ValidationError 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.db.models import Prefetch, Q, prefetch_related_objects
from django.forms import ( from django.forms import (
CheckboxSelectMultiple, formset_factory, inlineformset_factory, CheckboxSelectMultiple, formset_factory, inlineformset_factory,
@@ -65,6 +66,7 @@ from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import ( from pretix.base.settings import (
PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings, PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS, validate_event_settings,
) )
from pretix.base.validators import multimail_validate
from pretix.control.forms import ( from pretix.control.forms import (
MultipleLanguagesWidget, SlugWidget, SplitDateTimeField, MultipleLanguagesWidget, SlugWidget, SplitDateTimeField,
SplitDateTimePickerWidget, 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 " 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 " "here in percent. If you have a more complicated tax situation, you can add more tax rates and "
"detailed configuration later."), "detailed configuration later."),
max_value=Decimal("100.00"),
min_value=Decimal("0.00"),
required=False required=False
) )
@@ -861,13 +865,6 @@ class InvoiceSettingsForm(SettingsForm):
return data 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): def contains_web_channel_validate(val):
if "web" not in val: if "web" not in val:
raise ValidationError(_("The online shop must be selected to receive these emails.")) 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_text_resend_link': ['event', 'order'],
'mail_subject_resend_link': ['event', 'order'], 'mail_subject_resend_link': ['event', 'order'],
'mail_subject_resend_link_attendee': ['event', 'order'], 'mail_subject_resend_link_attendee': ['event', 'order'],
'mail_text_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'], 'mail_subject_waiting_list': ['event', 'waiting_list_entry', 'waiting_list_voucher'],
'mail_text_resend_all_links': ['event', 'orders'], 'mail_text_resend_all_links': ['event', 'orders'],
'mail_subject_resend_all_links': ['event', 'orders'], 'mail_subject_resend_all_links': ['event', 'orders'],
'mail_attach_ical_description': ['event', 'event_or_subevent'], 'mail_attach_ical_description': ['event', 'event_or_subevent'],

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(' ', '')

View File

@@ -1703,9 +1703,7 @@ class CheckinListAttendeeFilterForm(FilterForm):
if s == '1': if s == '1':
qs = qs.filter(last_entry__isnull=False) qs = qs.filter(last_entry__isnull=False)
elif s == '2': elif s == '2':
qs = qs.filter(last_entry__isnull=False).filter( qs = qs.filter(pk__in=self.list.positions_inside.values_list('pk'))
Q(last_exit__isnull=True) | Q(last_exit__lt=F('last_entry'))
)
elif s == '3': elif s == '3':
qs = qs.filter(last_entry__isnull=False).filter( qs = qs.filter(last_entry__isnull=False).filter(
Q(last_exit__isnull=False) & Q(last_exit__gte=F('last_entry')) Q(last_exit__isnull=False) & Q(last_exit__gte=F('last_entry'))
@@ -2264,7 +2262,7 @@ class DeviceFilterForm(FilterForm):
state = forms.ChoiceField( state = forms.ChoiceField(
label=_('Device status'), label=_('Device status'),
choices=[ choices=[
('', _('All devices')), ('all', _('All devices')),
('active', _('Active devices')), ('active', _('Active devices')),
('revoked', _('Revoked devices')) ('revoked', _('Revoked devices'))
], ],

View File

@@ -295,6 +295,7 @@ class ItemCreateForm(I18nModelForm):
self.user = kwargs.pop('user') self.user = kwargs.pop('user')
kwargs.setdefault('initial', {}) kwargs.setdefault('initial', {})
kwargs['initial'].setdefault('admission', True) kwargs['initial'].setdefault('admission', True)
kwargs['initial'].setdefault('personalized', True)
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
self.fields['category'].queryset = self.instance.event.categories.all() self.fields['category'].queryset = self.instance.event.categories.all()
@@ -403,6 +404,8 @@ class ItemCreateForm(I18nModelForm):
self.instance.sales_channels = list(get_all_sales_channels().keys()) self.instance.sales_channels = list(get_all_sales_channels().keys())
self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1 self.instance.position = (self.event.items.aggregate(p=Max('position'))['p'] or 0) + 1
if not self.instance.admission:
self.instance.personalized = False
instance = super().save(*args, **kwargs) instance = super().save(*args, **kwargs)
if not self.event.has_subevents and not self.cleaned_data.get('has_variations'): if not self.event.has_subevents and not self.cleaned_data.get('has_variations'):
@@ -494,6 +497,7 @@ class ItemCreateForm(I18nModelForm):
'internal_name', 'internal_name',
'category', 'category',
'admission', 'admission',
'personalized',
'default_price', 'default_price',
'tax_rule', 'tax_rule',
] ]
@@ -588,13 +592,33 @@ class ItemUpdateForm(I18nModelForm):
'tax_rule', '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.") _("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( self.add_error(
'admission', 'admission',
_( _(
"Gift card products should not be admission products at the same time." "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 return d
def clean_picture(self): def clean_picture(self):
@@ -615,6 +639,7 @@ class ItemUpdateForm(I18nModelForm):
'active', 'active',
'sales_channels', 'sales_channels',
'admission', 'admission',
'personalized',
'description', 'description',
'picture', 'picture',
'default_price', 'default_price',
@@ -792,6 +817,17 @@ 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): def save(self, commit=True):
instance = super().save(commit) instance = super().save(commit)
self.meta_fields = [] self.meta_fields = []

View File

@@ -227,6 +227,10 @@ class ExporterForm(forms.Form):
elif isinstance(v, models.QuerySet): elif isinstance(v, models.QuerySet):
data[k] = [m.pk for m in v] 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 return data
@@ -266,7 +270,7 @@ class OtherOperationsForm(forms.Form):
notify = forms.BooleanField( notify = forms.BooleanField(
label=_('Notify user'), label=_('Notify user'),
required=False, required=False,
initial=True, initial=False,
help_text=_( help_text=_(
'Send an email to the customer notifying that their order has been changed.' 'Send an email to the customer notifying that their order has been changed.'
) )

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

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 # You should have received a copy of the GNU Affero General Public License along with this program. If not, see
# <https://www.gnu.org/licenses/>. # <https://www.gnu.org/licenses/>.
# #
from datetime import datetime, timedelta from datetime import datetime
from urllib.parse import urlencode from urllib.parse import urlencode
from django import forms from django import forms
from django.forms import formset_factory from django.forms import formset_factory
from django.forms.utils import ErrorDict from django.forms.utils import ErrorDict
from django.urls import reverse from django.urls import reverse
from django.utils.dates import MONTHS, WEEKDAYS
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_lazy as _, pgettext_lazy
from i18nfield.forms import I18nInlineFormSet from i18nfield.forms import I18nInlineFormSet
from pretix.base.forms import I18nModelForm 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.reldate import RelativeDateTimeField, RelativeDateWrapper
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget from pretix.control.forms import SplitDateTimeField, SplitDateTimePickerWidget
from pretix.control.forms.rrule import RRuleForm
from pretix.helpers.money import change_decimal_field from pretix.helpers.money import change_decimal_field
@@ -440,166 +439,15 @@ class CheckinListFormSet(I18nInlineFormSet):
return form return form
class RRuleForm(forms.Form): class RRuleFormSetForm(RRuleForm):
# TODO: calendar.setfirstweekday
exclude = forms.BooleanField( exclude = forms.BooleanField(
label=_('Exclude these dates instead of adding them.'), label=_('Exclude these dates instead of adding them.'),
required=False 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( RRuleFormSet = formset_factory(
RRuleForm, RRuleFormSetForm,
can_order=False, can_delete=True, extra=1 can_order=False, can_delete=True, extra=1
) )

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.changed': _('The organizer has been changed.'),
'pretix.organizer.settings': _('The organizer settings have been changed.'), 'pretix.organizer.settings': _('The organizer settings have been changed.'),
'pretix.organizer.footerlinks.changed': _('The footer links 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.added': _('Gift card acceptance for another organizer has been added.'),
'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'), 'pretix.giftcards.acceptance.removed': _('Gift card acceptance for another organizer has been removed.'),
'pretix.webhook.created': _('The webhook has been created.'), '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.done': _('Refund {local_id} has been completed.'),
'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'), 'pretix.event.order.refund.canceled': _('Refund {local_id} has been canceled.'),
'pretix.event.order.refund.failed': _('Refund {local_id} has failed.'), '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.control.auth.user.created': _('The user has been created.'),
'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'), 'pretix.user.settings.2fa.enabled': _('Two-factor authentication has been enabled.'),
'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'), 'pretix.user.settings.2fa.disabled': _('Two-factor authentication has been disabled.'),

View File

@@ -112,7 +112,7 @@ class PermissionMiddleware:
url = resolve(request.path_info) url = resolve(request.path_info)
url_name = url.url_name url_name = url.url_name
if not request.path.startswith(get_script_prefix() + 'control'): if not request.path.startswith(get_script_prefix() + 'control') and not (url.namespace.startswith("api-") and url_name == "authorize"):
# This middleware should only touch the /control subpath # This middleware should only touch the /control subpath
return self.get_response(request) return self.get_response(request)

View File

@@ -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/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/main.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/quota.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/subevent.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/question.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> <script type="text/javascript" src="{% static "pretixcontrol/js/ui/variations.js" %}"></script>
@@ -422,6 +423,17 @@
</div> </div>
{% endif %} {% endif %}
{% if "mysql" in settings.DATABASES.default.ENGINE and not request.organizer %}
<div class="alert alert-warning">
{% blocktrans trimmed %}
You are using MySQL or MariaDB as your database backend for pretix.
Starting in pretix 5.0, these will no longer be supported and you will need to migrate to PostgreSQL.
Please see the pretix administrator documentation for a migration guide, and the pretix 4.16
release notes for more information.
{% endblocktrans %}
</div>
{% endif %}
{% if debug_warning %} {% if debug_warning %}
<div class="alert alert-danger"> <div class="alert alert-danger">
{% trans "pretix is running in debug mode. For security reasons, please never run debug mode on a production instance." %} {% trans "pretix is running in debug mode. For security reasons, please never run debug mode on a production instance." %}

View File

@@ -24,7 +24,7 @@
<dt>{% trans "Receipt number" context "terminal_zvt" %}</dt> <dt>{% trans "Receipt number" context "terminal_zvt" %}</dt>
<dd>{{ payment_info.payment_data.receiptNumber }}</dd> <dd>{{ payment_info.payment_data.receiptNumber }}</dd>
<dt>{% trans "Card type" context "terminal_zvt" %}</dt> <dt>{% trans "Card type" context "terminal_zvt" %}</dt>
<dd>{{ payment_info.payment_data.cardName }}</dd> <dd>{{ payment_info.payment_data.cardName|default_if_none:payment_info.payment_data.cardType }}</dd>
<dt>{% trans "Card expiration" context "terminal_zvt" %}</dt> <dt>{% trans "Card expiration" context "terminal_zvt" %}</dt>
<dd>{{ payment_info.payment_data.expiry }}</dd> <dd>{{ payment_info.payment_data.expiry }}</dd>
{% elif payment_info.payment_type == "sumup" %} {% elif payment_info.payment_type == "sumup" %}
@@ -98,5 +98,8 @@
<dd>{{ payment_info.payment_data.posEntryMode }}</dd> <dd>{{ payment_info.payment_data.posEntryMode }}</dd>
<dt>{% trans "Result Code" %}</dt> <dt>{% trans "Result Code" %}</dt>
<dd>{{ payment_info.payment_data.posResultCode }}</dd> <dd>{{ payment_info.payment_data.posResultCode }}</dd>
{% elif payment_info.payment_type == "cash" %}
<dt>{% trans "Payment method" %}</dt>
<dd>{% trans "Cash" %}</dd>
{% endif %} {% endif %}
</dl> </dl>

View File

@@ -82,8 +82,10 @@
<thead> <thead>
<tr> <tr>
<th> <th>
<label aria-label="{% trans "select all rows for batch-operation" %}" {% if "can_change_orders" in request.eventpermset or "can_checkin_orders" in request.eventpermset %}
class="batch-select-label"><input type="checkbox" data-toggle-table/></label> <label aria-label="{% trans "select all rows for batch-operation" %}"
class="batch-select-label"><input type="checkbox" data-toggle-table/></label>
{% endif %}
</th> </th>
<th>{% trans "Order code" %} <a href="?{% url_replace request 'ordering' '-code'%}"><i class="fa fa-caret-down"></i></a> <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> <a href="?{% url_replace request 'ordering' 'code'%}"><i class="fa fa-caret-up"></i></a></th>
@@ -125,7 +127,7 @@
{% for e in entries %} {% for e in entries %}
<tr> <tr>
<td> <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 }}"/> <input type="checkbox" name="checkin" id="id_checkin" class="" value="{{ e.pk }}"/>
{% endif %} {% endif %}
</td> </td>
@@ -198,7 +200,7 @@
</tbody> </tbody>
</table> </table>
</div> </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"> <button type="submit" class="btn btn-primary btn-save">
<span class="fa fa-sign-in" aria-hidden="true"></span> <span class="fa fa-sign-in" aria-hidden="true"></span>
{% trans "Check-In selected attendees" %} {% trans "Check-In selected attendees" %}
@@ -207,6 +209,8 @@
<span class="fa fa-sign-out" aria-hidden="true"></span> <span class="fa fa-sign-out" aria-hidden="true"></span>
{% trans "Check-Out selected attendees" %} {% trans "Check-Out selected attendees" %}
</button> </button>
{% endif %}
{% if "can_change_orders" in request.eventpermset %}
<button type="submit" class="btn btn-danger btn-save" name="revert" value="true"> <button type="submit" class="btn btn-danger btn-save" name="revert" value="true">
<span class="fa fa-trash" aria-hidden="true"></span> <span class="fa fa-trash" aria-hidden="true"></span>
{% trans "Delete all check-ins of selected attendees" %} {% trans "Delete all check-ins of selected attendees" %}

View File

@@ -36,12 +36,16 @@
</div> </div>
<div id="{{ item }}_preview" class="tab-pane mail-preview-group"> <div id="{{ item }}_preview" class="tab-pane mail-preview-group">
{% if request.event %} {% if request.event %}
{% for l in request.event.settings.locales %} {% for l, n in settings.LANGUAGES %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div> {% if l in request.event.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endif %}
{% endfor %} {% endfor %}
{% else %} {% else %}
{% for l in request.organizer.settings.locales %} {% for l, n in settings.LANGUAGES %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div> {% if l in request.organizer.settings.locales %}
<div lang="{{ l }}" for="{{ item }}" class="mail-preview"></div>
{% endif %}
{% endfor %} {% endfor %}
{% endif %} {% endif %}
</div> </div>

View File

@@ -90,7 +90,7 @@
</div> </div>
</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_names_asked_required layout="control" %}
{% bootstrap_field sform.attendee_emails_asked_required layout="control" %} {% bootstrap_field sform.attendee_emails_asked_required layout="control" %}

View File

@@ -20,13 +20,19 @@
<div class="col-md-9"> <div class="col-md-9">
<div class="big-radio radio"> <div class="big-radio radio">
<label> <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-user"></span> <span class="fa fa-fw fa-user"></span>
<strong>{% trans "Admission product" %}</strong><br> <strong>{% trans "Admission product" %}</strong><br>
<div class="help-block"> <div class="help-block">
{% blocktrans trimmed %} {% blocktrans trimmed %}
Every purchase of this product represents one person who is allowed to enter your event. 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 %} {% endblocktrans %}
</div> </div>
<div class="help-block"> <div class="help-block">
@@ -40,12 +46,12 @@
<div class="big-radio radio"> <div class="big-radio radio">
<label> <label>
<input type="radio" value="" name="{{ form.admission.html_name }}" {% if not form.admission.value %}checked{% endif %}> <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> <strong>{% trans "Non-admission product" %}</strong>
<div class="help-block"> <div class="help-block">
{% blocktrans trimmed %} {% blocktrans trimmed %}
A product that does not represent a person. By default, pretix will not ask for attendee information or offer A product that does not represent a person. By default, we will not offer ticket downloads
ticket downloads. (but you can still enable ticket downloads in event settings or product settings).
{% endblocktrans %} {% endblocktrans %}
</div> </div>
<div class="help-block"> <div class="help-block">
@@ -58,6 +64,47 @@
</div> </div>
</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" %} {% bootstrap_field form.category layout="control" %}
<div class="form-group"> <div class="form-group">

View File

@@ -27,13 +27,19 @@
{% endfor %} {% endfor %}
<div class="big-radio radio"> <div class="big-radio radio">
<label> <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> <span class="fa fa-fw fa-user"></span>
<strong>{% trans "Admission product" %}</strong><br> <strong>{% trans "Admission product" %}</strong><br>
<div class="help-block"> <div class="help-block">
{% blocktrans trimmed %} {% blocktrans trimmed %}
Every purchase of this product represents one person who is allowed to enter your event. 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 %} {% endblocktrans %}
</div> </div>
<div class="help-block"> <div class="help-block">
@@ -51,8 +57,8 @@
<strong>{% trans "Non-admission product" %}</strong> <strong>{% trans "Non-admission product" %}</strong>
<div class="help-block"> <div class="help-block">
{% blocktrans trimmed %} {% blocktrans trimmed %}
A product that does not represent a person. By default, pretix will not ask for attendee information or offer A product that does not represent a person. By default, we will not offer ticket downloads
ticket downloads. (but you can still enable ticket downloads in event settings or product settings).
{% endblocktrans %} {% endblocktrans %}
</div> </div>
<div class="help-block"> <div class="help-block">
@@ -65,6 +71,52 @@
</div> </div>
</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.description layout="control" %}
{% bootstrap_field form.picture layout="control" %} {% bootstrap_field form.picture layout="control" %}
{% bootstrap_field form.require_approval layout="control" %} {% bootstrap_field form.require_approval layout="control" %}

View File

@@ -81,7 +81,11 @@
</td> </td>
<td> <td>
{% if i.admission %} {% 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 %} {% elif i.issue_giftcard %}
<span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span> <span class="fa fa-gift fa-fw text-muted" data-toggle="tooltip" title="{% trans "Gift card" %}"></span>
{% endif %} {% endif %}

View File

@@ -76,7 +76,7 @@
{% endfor %} {% endfor %}
</ul> </ul>
{% else %} {% else %}
<small>{% trans "All admission products" %}</small> <small>{% trans "All personalized products" %}</small>
{% endif %} {% endif %}
</td> </td>
<td class="dnd-container"> <td class="dnd-container">

View File

@@ -184,9 +184,11 @@
<dt>{% trans "Order locale" %}</dt> <dt>{% trans "Order locale" %}</dt>
<dd> <dd>
{{ display_locale }} {{ 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"> {% if "can_change_orders" in request.eventpermset %}
<span class="fa fa-edit"></span> <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">
</a> <span class="fa fa-edit"></span>
</a>
{% endif %}
</dd> </dd>
{% if order.status == "n" %} {% if order.status == "n" %}
<dt>{% trans "Expiry date" %}</dt> <dt>{% trans "Expiry date" %}</dt>
@@ -206,9 +208,11 @@
{{ order.customer.identifier }} {{ order.customer.email }} {{ order.customer.identifier }} {{ order.customer.email }}
</a> </a>
{% endif %} {% 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"> {% if "can_change_orders" in request.eventpermset %}
<span class="fa fa-edit"></span> <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">
</a> <span class="fa fa-edit"></span>
</a>
{% endif %}
</dd> </dd>
{% endif %} {% endif %}
<dt>{% trans "Contact email" %}</dt> <dt>{% trans "Contact email" %}</dt>
@@ -217,21 +221,23 @@
{% if order.email and order.email_known_to_work %} {% 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> <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 %} {% 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"> {% if "can_change_orders" in request.eventpermset %}
<span class="fa fa-edit"></span> <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">
</a> <span class="fa fa-edit"></span>
{% 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> </a>
{% if order.status != "c" %} {% if order.email %}
<form class="form-inline helper-display-inline" method="post" <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">
action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}"> <span class="fa fa-envelope-o"></span>
{% csrf_token %} </a>
<button class="btn btn-default btn-xs"> {% if order.status != "c" %}
{% trans "Resend link" %} <form class="form-inline helper-display-inline" method="post"
</button> action="{% url "control:event.order.resendlink" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
</form> {% csrf_token %}
<button class="btn btn-default btn-xs">
{% trans "Resend link" %}
</button>
</form>
{% endif %}
{% endif %} {% endif %}
{% endif %} {% endif %}
</dd> </dd>
@@ -239,9 +245,11 @@
<dt>{% trans "Phone number" %}</dt> <dt>{% trans "Phone number" %}</dt>
<dd> <dd>
{{ order.phone|default_if_none:""|phone_format }} {{ 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"> {% if "can_change_orders" in request.eventpermset %}
<span class="fa fa-edit"></span> <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">
</a> <span class="fa fa-edit"></span>
</a>
{% endif %}
</dd> </dd>
{% endif %} {% endif %}
{% if invoices %} {% if invoices %}
@@ -306,7 +314,7 @@
{% trans "Email invoices" %} {% trans "Email invoices" %}
</a> </a>
{% endif %} {% endif %}
{% if can_generate_invoice %} {% if can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<br/> <br/>
<form class="form-inline helper-display-inline" method="post" <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 %}"> action="{% url "control:event.order.geninvoice" event=request.event.slug organizer=request.event.organizer.slug code=order.code %}">
@@ -317,7 +325,7 @@
</form> </form>
{% endif %} {% endif %}
</dd> </dd>
{% elif can_generate_invoice %} {% elif can_generate_invoice and 'can_change_orders' in request.eventpermset %}
<dt>{% trans "Invoices" %}</dt> <dt>{% trans "Invoices" %}</dt>
<dd> <dd>
<form class="form-inline helper-display-inline" method="post" <form class="form-inline helper-display-inline" method="post"
@@ -335,11 +343,11 @@
<div class="panel panel-default items"> <div class="panel panel-default items">
<div class="panel-heading"> <div class="panel-heading">
<div class="pull-right flip"> <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 %} {% 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 %}"> &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> <span class="fa fa-edit"></span>
{% trans "Change products" %} {% trans "Change products" %}
@@ -460,12 +468,12 @@
{% endif %} {% endif %}
{% if line.has_questions %} {% if line.has_questions %}
<dl> <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> <dt>{% trans "Attendee name" %}</dt>
<dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %} <dd>{% if line.attendee_name %}{{ line.attendee_name }}{% else %}
<em>{% trans "not answered" %}</em>{% endif %}</dd> <em>{% trans "not answered" %}</em>{% endif %}</dd>
{% endif %} {% 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> <dt>{% trans "Attendee email" %}</dt>
<dd> <dd>
{% if line.attendee_email %} {% if line.attendee_email %}
@@ -488,7 +496,7 @@
{% endif %} {% endif %}
</dd> </dd>
{% endif %} {% endif %}
{% if line.item.admission and event.settings.attendee_company_asked %} {% if line.item.ask_attendee_data and event.settings.attendee_company_asked %}
<dt> <dt>
{% trans "Attendee company" %} {% trans "Attendee company" %}
</dt> </dt>
@@ -496,7 +504,7 @@
{% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %} {% if line.company %}{{ line.company }}{% else %}<em>{% trans "not answered" %}</em>{% endif %}
</dd> </dd>
{% endif %} {% endif %}
{% if line.item.admission and event.settings.attendee_addresses_asked %} {% if line.item.ask_attendee_data and event.settings.attendee_addresses_asked %}
<dt> <dt>
{% trans "Attendee address" %} {% trans "Attendee address" %}
</dt> </dt>
@@ -754,7 +762,7 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </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"> <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" %} {% trans "Create a refund" %}
</a> </a>
@@ -942,9 +950,11 @@
{% bootstrap_field comment_form.comment show_help=True show_label=False %} {% bootstrap_field comment_form.comment show_help=True show_label=False %}
{% bootstrap_field comment_form.custom_followup_at %} {% bootstrap_field comment_form.custom_followup_at %}
{% bootstrap_field comment_form.checkin_attention show_help=True show_label=False %} {% bootstrap_field comment_form.checkin_attention show_help=True show_label=False %}
<button class="btn btn-default"> {% if "can_change_orders" in request.eventpermset %}
{% trans "Update comment" %} <button class="btn btn-default">
</button> {% trans "Update comment" %}
</button>
{% endif %}
</form> </form>
</div> </div>
</div> </div>

View File

@@ -4,35 +4,113 @@
{% load order_overview %} {% load order_overview %}
{% block title %}{% trans "Data export" %}{% endblock %} {% block title %}{% trans "Data export" %}{% endblock %}
{% block content %} {% block content %}
<h1> <h1>
{% trans "Data export" %} {% trans "Data export" %}
{% if "identifier" in request.GET %}
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
{% endif %}
</h1> </h1>
{% for e in exporters %} {% if scheduled %}
<details class="panel panel-default" <h2>{% trans "Scheduled exports" %}</h2>
{% if request.GET.identifier == e.identifier or request.POST.exporter == e.identifier %}open{% endif %}> <ul class="list-group">
<summary class="panel-heading"> {% for s in scheduled %}
<h3 class="panel-title"> <li class="list-group-item logentry">
{{ e.verbose_name }} <div class="row">
<i class="fa fa-angle-down collapse-indicator"></i> <div class="col-lg-5 col-md-4 col-xs-12">
</h3> <span class="fa fa-fw fa-folder"></span>
</summary> {{ s.export_verbose_name }}
<div id="{{ e.identifier }}"> <br>
<div class="panel-body"> <span class="text-muted">
<form action="{% url "control:event.orders.export.do" event=request.event.slug organizer=request.organizer.slug %}" <span class="fa fa-fw fa-user"></span>
method="post" class="form-horizontal" data-asynctask data-asynctask-download {{ s.owner.fullname|default:s.owner.email }}
data-asynctask-long> </span>
{% csrf_token %} </div>
<input type="hidden" name="exporter" value="{{ e.identifier }}" /> <div class="col-lg-5 col-md-6 col-xs-12">
{% bootstrap_form e.form layout='control' %} {% if s.schedule_next_run %}
<button class="btn btn-primary pull-right flip" type="submit"> <span class="fa fa-clock-o fa-fw"></span>
<span class="icon icon-upload"></span> {% trans "Start export" %} {% trans "Next run:" %}
</button> {{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
</form> {% else %}
</div> <span class="fa fa-clock-o fa-fw"></span>
</div> {% trans "No next run scheduled" %}
</details> {% 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 %} {% endfor %}
{% endblock %} {% endblock %}

View File

@@ -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 %}

View File

@@ -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 %}

View File

@@ -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>

View File

@@ -1,37 +1,110 @@
{% extends "pretixcontrol/event/base.html" %} {% extends "pretixcontrol/organizers/base.html" %}
{% load i18n %} {% load i18n %}
{% load bootstrap3 %} {% load bootstrap3 %}
{% load order_overview %} {% load order_overview %}
{% block title %}{% trans "Data export" %}{% endblock %} {% block title %}{% trans "Data export" %}{% endblock %}
{% block content %} {% block content %}
<h1> <h1>
{% trans "Data export" %} {% trans "Data export" %}
{% if "identifier" in request.GET %}
<a href="?" class="btn btn-default">{% trans "Show all" %}</a>
{% endif %}
</h1> </h1>
{% for e in exporters %} {% if scheduled %}
<details class="panel panel-default" {% if "identifier" in request.GET or "exporter" in request.POST %}open{% endif %}> <h2>{% trans "Scheduled exports" %}</h2>
<summary class="panel-heading"> <ul class="list-group">
<h3 class="panel-title"> {% for s in scheduled %}
{{ e.verbose_name }} <li class="list-group-item logentry">
<i class="fa fa-angle-down collapse-indicator"></i> <div class="row">
</h3> <div class="col-lg-5 col-md-4 col-xs-12">
</summary> <span class="fa fa-fw fa-folder"></span>
<div id="{{ e.identifier }}"> {{ s.export_verbose_name }}
<div class="panel-body"> <br>
<form action="{% url "control:organizer.export.do" organizer=request.organizer.slug %}" <span class="text-muted">
method="post" class="form-horizontal" data-asynctask data-asynctask-download <span class="fa fa-fw fa-user"></span>
data-asynctask-long> {{ s.owner.fullname|default:s.owner.email }}
{% csrf_token %} </span>
<input type="hidden" name="exporter" value="{{ e.identifier }}" /> </div>
{% bootstrap_form e.form layout='control' %} <div class="col-lg-5 col-md-6 col-xs-12">
<button class="btn btn-primary pull-right flip" type="submit"> {% if s.schedule_next_run %}
<span class="icon icon-upload"></span> {% trans "Start export" %} <span class="fa fa-clock-o fa-fw"></span>
</button> {% trans "Next run:" %}
</form> {{ s.schedule_next_run|date:"SHORT_DATETIME_FORMAT" }}
</div> {% else %}
</div> <span class="fa fa-clock-o fa-fw"></span>
</details> {% 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 %} {% endfor %}
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,19 @@
{% extends "pretixcontrol/organizers/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:organizer.export" organizer=request.organizer.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 %}

View File

@@ -0,0 +1,52 @@
{% extends "pretixcontrol/organizers/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: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="{{ 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 %}

View File

@@ -458,7 +458,9 @@
</form> </form>
</div> </div>
</div> </div>
<script type="text/plain" id="schema-url">{% static "schema/pdf-layout.schema.json" %}</script>
<script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script> <script type="text/javascript" src="{% static "pdfjs/pdf.js" %}"></script>
<script type="text/javascript" src="{% static "ajv/ajv2020.bundle.min.js" %}"></script>
<script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script> <script type="text/javascript" src="{% static "fabric/fabric.min.js" %}"></script>
<script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script> <script type="text/javascript" src="{% static "pretixcontrol/js/ui/editor.js" %}"></script>
<img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only"> <img src="{% static 'pretixpresale/pdf/powered_by_pretix_dark.png' %}" id="poweredby-dark" class="sr-only">

View File

@@ -207,6 +207,10 @@ urlpatterns = [
re_path(r'^organizer/(?P<organizer>[^/]+)/logs', organizer.LogView.as_view(), name='organizer.log'), re_path(r'^organizer/(?P<organizer>[^/]+)/logs', organizer.LogView.as_view(), name='organizer.log'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'), re_path(r'^organizer/(?P<organizer>[^/]+)/export/$', organizer.ExportView.as_view(), name='organizer.export'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'), re_path(r'^organizer/(?P<organizer>[^/]+)/export/do$', organizer.ExportDoView.as_view(), name='organizer.export.do'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/run$', organizer.RunScheduledExportView.as_view(),
name='organizer.export.scheduled.run'),
re_path(r'^organizer/(?P<organizer>[^/]+)/export/(?P<pk>[^/]+)/delete$', organizer.DeleteScheduledExportView.as_view(),
name='organizer.export.scheduled.delete'),
re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'), re_path(r'^nav/typeahead/$', typeahead.nav_context_list, name='nav.typeahead'),
re_path(r'^events/$', main.EventList.as_view(), name='events'), re_path(r'^events/$', main.EventList.as_view(), name='events'),
re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'), re_path(r'^events/add$', main.EventWizard.as_view(), name='events.add'),
@@ -386,6 +390,8 @@ urlpatterns = [
re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'), re_path(r'^orders/import/$', orderimport.ImportView.as_view(), name='event.orders.import'),
re_path(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'), re_path(r'^orders/import/(?P<file>[^/]+)/$', orderimport.ProcessView.as_view(), name='event.orders.import.process'),
re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'), re_path(r'^orders/export/$', orders.ExportView.as_view(), name='event.orders.export'),
re_path(r'^orders/export/(?P<pk>[^/]+)/run$', orders.RunScheduledExportView.as_view(), name='event.orders.export.scheduled.run'),
re_path(r'^orders/export/(?P<pk>[^/]+)/delete$', orders.DeleteScheduledExportView.as_view(), name='event.orders.export.scheduled.delete'),
re_path(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'), re_path(r'^orders/export/do$', orders.ExportDoView.as_view(), name='event.orders.export.do'),
re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'), re_path(r'^orders/refunds/$', orders.RefundList.as_view(), name='event.orders.refunds'),
re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'), re_path(r'^orders/go$', orders.OrderGo.as_view(), name='event.orders.go'),

View File

@@ -34,6 +34,7 @@
import dateutil.parser import dateutil.parser
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import PermissionDenied
from django.db import transaction from django.db import transaction
from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery from django.db.models import Exists, Max, OuterRef, Prefetch, Subquery
from django.http import Http404, HttpResponseRedirect from django.http import Http404, HttpResponseRedirect
@@ -42,7 +43,7 @@ from django.urls import reverse
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import is_aware, make_aware, now from django.utils.timezone import is_aware, make_aware, now
from django.utils.translation import gettext_lazy as _ from django.utils.translation import gettext_lazy as _
from django.views.generic import DeleteView, ListView from django.views.generic import ListView
from pytz import UTC from pytz import UTC
from pretix.base.channels import get_all_sales_channels from pretix.base.channels import get_all_sales_channels
@@ -56,6 +57,7 @@ from pretix.control.forms.filter import (
) )
from pretix.control.permissions import EventPermissionRequiredMixin from pretix.control.permissions import EventPermissionRequiredMixin
from pretix.control.views import CreateView, PaginationMixin, UpdateView from pretix.control.views import CreateView, PaginationMixin, UpdateView
from pretix.helpers.compat import CompatDeleteView
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
@@ -90,7 +92,12 @@ class CheckInListQueryMixin:
last_entry=Subquery(cqs), last_entry=Subquery(cqs),
last_exit=Subquery(cqs_exit), last_exit=Subquery(cqs_exit),
auto_checked_in=Exists( auto_checked_in=Exists(
Checkin.objects.filter(position_id=OuterRef('pk'), list_id=self.list.pk, auto_checked_in=True) Checkin.objects.filter(
position_id=OuterRef('pk'),
type=Checkin.TYPE_ENTRY,
list_id=self.list.pk,
auto_checked_in=True
)
) )
).select_related( ).select_related(
'item', 'variation', 'order', 'addon_to' 'item', 'variation', 'order', 'addon_to'
@@ -171,7 +178,7 @@ class CheckInListShow(EventPermissionRequiredMixin, PaginationMixin, CheckInList
class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncPostView): class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMixin, AsyncPostView):
template_name = 'pretixcontrol/organizers/device_bulk_edit.html' template_name = 'pretixcontrol/organizers/device_bulk_edit.html'
permission = 'can_change_orders' permission = ('can_change_orders', 'can_checkin_orders')
context_object_name = 'device' context_object_name = 'device'
def dispatch(self, request, *args, **kwargs): def dispatch(self, request, *args, **kwargs):
@@ -181,11 +188,16 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
def get_queryset(self): def get_queryset(self):
return super().get_queryset().prefetch_related(None).order_by() return super().get_queryset().prefetch_related(None).order_by()
def get_error_url(self):
return self.get_success_url(None)
@transaction.atomic() @transaction.atomic()
def async_post(self, request, *args, **kwargs): def async_post(self, request, *args, **kwargs):
self.list = get_object_or_404(request.event.checkin_lists.all(), pk=kwargs.get("list")) self.list = get_object_or_404(request.event.checkin_lists.all(), pk=kwargs.get("list"))
positions = self.get_queryset() positions = self.get_queryset()
if request.POST.get('revert') == 'true': if request.POST.get('revert') == 'true':
if not request.user.has_event_permission(request.organizer, request.event, 'can_change_orders', request=request):
raise PermissionDenied()
for op in positions: for op in positions:
if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING): if op.order.status == Order.STATUS_PAID or (self.list.include_pending and op.order.status == Order.STATUS_PENDING):
Checkin.objects.filter(position=op, list=self.list).delete() Checkin.objects.filter(position=op, list=self.list).delete()
@@ -240,7 +252,7 @@ class CheckInListBulkActionView(CheckInListQueryMixin, EventPermissionRequiredMi
'event': self.request.event.slug, 'event': self.request.event.slug,
'organizer': self.request.event.organizer.slug, 'organizer': self.request.event.organizer.slug,
'list': self.list.pk 'list': self.list.pk
}) + ('?' + value[1] if value[1] else '') }) + ('?' + value[1] if value and value[1] else '')
class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, ListView): class CheckinListList(EventPermissionRequiredMixin, PaginationMixin, ListView):
@@ -388,7 +400,7 @@ class CheckinListUpdate(EventPermissionRequiredMixin, UpdateView):
return super().form_invalid(form) return super().form_invalid(form)
class CheckinListDelete(EventPermissionRequiredMixin, DeleteView): class CheckinListDelete(EventPermissionRequiredMixin, CompatDeleteView):
model = CheckinList model = CheckinList
template_name = 'pretixcontrol/checkin/list_delete.html' template_name = 'pretixcontrol/checkin/list_delete.html'
permission = 'can_change_event_settings' permission = 'can_change_event_settings'

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