Compare commits

...

212 Commits

Author SHA1 Message Date
Raphael Michel
c1416fbdb8 Bump version to 3.0.1 2019-08-16 12:28:06 +02:00
Raphael Michel
1693affba4 Widget builder: explicit encoding for file reading 2019-08-16 12:27:13 +02:00
Raphael Michel
43d3eb3fe2 Order creation API: Do not consume expired carts 2019-08-16 12:26:40 +02:00
Raphael Michel
9286ca14f9 Bump version to 3.0.0 2019-08-08 10:48:27 +02:00
Raphael Michel
c5f9a78bdb Update from Weblate (#1361)
Update from Weblate
2019-08-08 10:48:13 +02:00
Raphael Michel
08eb5bfb8f Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3211 of 3211 strings)

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

powered by weblate
2019-08-08 08:47:28 +00:00
Raphael Michel
804e33b773 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3211 of 3211 strings)

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

powered by weblate
2019-08-08 08:47:28 +00:00
Raphael Michel
c264d8bd5b Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-08-08 09:55:08 +02:00
Raphael Michel
cd7be48cf2 Merge pull request #1359 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-08-07 15:47:42 +02:00
Raphael Michel
2290b00161 MIMEEncode inline images as CID (#1358)
MIMEEncode inline images as CID
2019-08-07 15:47:22 +02:00
Raphael Michel
9fb2d3a43b Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 97.5% (3128 of 3209 strings)

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

powered by weblate
2019-08-07 13:39:12 +00:00
Raphael Michel
d0f3c24b2a Fix button without voucher 2019-08-07 15:39:03 +02:00
Martin Gross
94e2c2fa3c Deduplicate CID images 2019-08-07 13:24:13 +02:00
Martin Gross
a0e3bbcc82 Fix payment cancelation of Stripe sources 2019-08-07 10:11:07 +02:00
Raphael Michel
9a9de523e0 Allow separate numbering schemes for invoices and cancellations 2019-08-06 14:18:31 +02:00
Raphael Michel
6dd1c927ef Add fail_on_no_quotas parameter to Item.check_quotas 2019-08-06 14:08:34 +02:00
Raphael Michel
51446574e2 Do not allow misleading NULL value in mail_days_order_expire_warning 2019-08-06 11:09:17 +02:00
Raphael Michel
cfbfb74996 Move method once again 2019-08-06 11:03:52 +02:00
Raphael Michel
527a250435 Deal with bundled products with no quotas
Fix PRETIXEU-1A8
2019-08-06 10:31:57 +02:00
Raphael Michel
87fb5f06ff Move method to correct class
Fix PRETIXEU-1A4
2019-08-06 10:22:04 +02:00
Raphael Michel
661cba876f French gets a capital F 2019-08-06 10:02:24 +02:00
Raphael Michel
be37e3635b Merge pull request #1355 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-08-06 10:02:16 +02:00
Raphael Michel
8bc4793f4e Translated on translate.pretix.eu (French)
Currently translated at 73.0% (2342 of 3209 strings)

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

powered by weblate
2019-08-06 08:00:32 +00:00
Maarten van den Berg
1604d0bf7a Translated on translate.pretix.eu (Dutch)
Currently translated at 98.6% (3165 of 3209 strings)

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

powered by weblate
2019-08-05 10:16:39 +00:00
Maarten van den Berg
f042932d1d Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (103 of 103 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl_Informal/

powered by weblate
2019-08-05 10:16:39 +00:00
Maarten van den Berg
bfc6422e6e Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (103 of 103 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl/

powered by weblate
2019-08-05 10:16:39 +00:00
Maarten van den Berg
942feb09fc Translated on translate.pretix.eu (Dutch)
Currently translated at 97.6% (3131 of 3209 strings)

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

powered by weblate
2019-08-05 10:16:39 +00:00
Martin Gross
b372ce84a5 Translated on translate.pretix.eu (French)
Currently translated at 64.1% (66 of 103 strings)

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

powered by weblate
2019-08-05 10:16:39 +00:00
Martin Gross
7c0c7202da Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 98.1% (101 of 103 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl_Informal/

powered by weblate
2019-08-05 10:16:39 +00:00
Martin Gross
b8bf5ce2d3 Translated on translate.pretix.eu (Dutch)
Currently translated at 98.1% (101 of 103 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl/

powered by weblate
2019-08-05 10:16:39 +00:00
Martin Gross
d25a9d077d Translated on translate.pretix.eu (French)
Currently translated at 73.0% (2341 of 3209 strings)

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

powered by weblate
2019-08-05 10:16:39 +00:00
Martin Gross
4a4dad3d5c Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 97.5% (3128 of 3209 strings)

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

powered by weblate
2019-08-05 10:16:39 +00:00
Christophe Piret
7b6b83eaf4 Translated on translate.pretix.eu (French)
Currently translated at 73.0% (2341 of 3209 strings)

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

powered by weblate
2019-08-05 10:16:39 +00:00
Martin Gross
bf54222cac Handle PayPal-declines if organizer-account is blocked by risk 2019-08-05 12:16:29 +02:00
Martin Gross
d37939bc2a Change BS-parser to lxml insted of html5lib, do prevent another dependency 2019-08-04 17:25:02 +02:00
Martin Gross
4b3f6ba94b Fix urlparse import 2019-08-04 14:55:54 +02:00
Martin Gross
18c8933c64 MIMEEncode inline images as CID 2019-08-04 14:46:47 +02:00
Martin Gross
6a6a84e8c8 Fix eMail-renderer documentation 2019-08-01 20:41:04 +02:00
Raphael Michel
32edf4b833 Fix attributeerror 2019-07-30 18:18:28 +02:00
Raphael Michel
35ae7e4968 Tabs: Do not hide HTMl5 validation 2019-07-30 14:38:16 +02:00
Raphael Michel
b5fb48a55f Widget: Remove confusion with resuming sessions 2019-07-30 14:16:33 +02:00
Raphael Michel
814364fbda Add waitinglist to word whitelist 2019-07-29 16:52:35 +02:00
Raphael Michel
5bff5053be Update from Weblate (#1354)
Update from Weblate
2019-07-29 16:34:48 +02:00
Raphael Michel
32f4813d33 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3209 of 3209 strings)

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

powered by weblate
2019-07-29 14:34:29 +00:00
Raphael Michel
871a677e5e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3209 of 3209 strings)

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

powered by weblate
2019-07-29 14:33:59 +00:00
Raphael Michel
95a777516e Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-07-29 16:28:32 +02:00
Raphael Michel
ad8f109e77 Add Item.allow_waitinglist 2019-07-29 16:27:27 +02:00
Raphael Michel
c60d1c8a5d Subevent editor: Redirect back to same page/filter 2019-07-29 16:05:13 +02:00
Raphael Michel
49288ff4e5 Fix incorrect link 2019-07-29 15:39:59 +02:00
Raphael Michel
e90356546f Backend: Modify order information: Do not send email by default 2019-07-29 15:35:14 +02:00
Raphael Michel
a664d51dbc Persist and show full "path" of seats 2019-07-29 15:23:09 +02:00
Raphael Michel
79ee851fae Fix broken order process 2019-07-29 14:59:36 +02:00
Raphael Michel
00905836dc Merge pull request #1353 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-07-29 10:36:58 +02:00
Raphael Michel
e5f57c8ff4 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-07-29 08:36:22 +00:00
Raphael Michel
c4cbfc726c Translated on translate.pretix.eu (German)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-07-29 08:36:22 +00:00
Raphael Michel
869694a026 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3200 of 3200 strings)

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

powered by weblate
2019-07-29 08:36:21 +00:00
Raphael Michel
843f28d94e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3200 of 3200 strings)

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

powered by weblate
2019-07-29 08:36:20 +00:00
Raphael Michel
ce35551e97 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-07-29 10:18:15 +02:00
Raphael Michel
3dae8bcdec Widget: Do not label button "Buy" if all items are free 2019-07-29 10:17:33 +02:00
Raphael Michel
3763edbc57 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-07-29 09:35:42 +02:00
Raphael Michel
c1d89284a4 Use tabs for all long settings and CRUD forms (#1352)
* First tabs

* Convert more pages

* Convert question page

* Item form

* Add item_formsets signal

* Revert "Add new signal nav_item"

This reverts commit 1ce613ff89.

* Formset is a word!
2019-07-29 09:35:00 +02:00
Raphael Michel
609f0b632c Do not block "add to cart" button when seating is used 2019-07-28 16:06:14 +02:00
Raphael Michel
10aeadf835 Do not show +/- icons for cart rows with seats 2019-07-28 16:06:00 +02:00
Raphael Michel
26726043c2 Update seating plan schema 2019-07-27 16:48:47 +02:00
Martin Gross
34d1fcf077 Add PayPal-Partner-Attribution-Id to PayPal API-Calls 2019-07-26 10:59:57 +02:00
Raphael Michel
e83e8cdcc0 Allow to hide a product unless a specific quota is sold out (#1351)
* Allow to hide a product unless a specific quota is sold out

* Fix required property

* Add API property and copy between events
2019-07-25 16:14:24 +02:00
Raphael Michel
2dd75ea252 Hide fees on changing payment method when no fees are taken 2019-07-25 11:47:23 +02:00
Raphael Michel
4857cfad6e Fix another waiting list bug with subevents 2019-07-25 10:49:18 +02:00
Raphael Michel
55f8e1c123 Fix waiting list assignment for subevents 2019-07-25 09:39:05 +02:00
Raphael Michel
6df1960f79 Use robust plugin calling in runperiodic 2019-07-25 09:20:34 +02:00
Raphael Michel
3091139aab Update from Weblate (#1350)
Update from Weblate
2019-07-24 15:57:33 +02:00
Raphael Michel
020c7faaef Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3191 of 3191 strings)

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

powered by weblate
2019-07-24 13:56:48 +00:00
Raphael Michel
e9b26cc51e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3191 of 3191 strings)

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

powered by weblate
2019-07-24 13:56:47 +00:00
Raphael Michel
7948cefee1 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (102 of 102 strings)

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

powered by weblate
2019-07-24 13:45:19 +00:00
Raphael Michel
62195f14be Translated on translate.pretix.eu (German)
Currently translated at 100.0% (102 of 102 strings)

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

powered by weblate
2019-07-24 13:45:19 +00:00
Martin Gross
5a216b7be9 Fix Stripe refunds for PaymentIntents 2019-07-24 15:37:39 +02:00
Raphael Michel
20d79152a6 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-07-24 15:16:28 +02:00
Raphael Michel
d97a0b1941 Consistent display of price ranges 2019-07-24 15:13:10 +02:00
Raphael Michel
fe6e65ccb0 erge remote-tracking branch 'origin/pretixscan' 2019-07-23 19:08:35 +02:00
Raphael Michel
9886f22b83 Add pretix' Stripe partner ID 2019-07-23 09:50:23 +02:00
Sohalt
591ed969b8 Autofocus login form (#1346) 2019-07-22 14:31:18 +02:00
Raphael Michel
3ab475ba6d Fix order page 2019-07-18 19:45:05 +02:00
Raphael Michel
307b1a2748 Fix that allow_cancel is 0 for UI-created events 2019-07-18 17:38:12 +02:00
Raphael Michel
cb3f3f5084 Advertise pretixSCAN 2019-07-18 17:26:49 +02:00
Raphael Michel
85edbe4837 Improved device validation 2019-07-18 17:26:34 +02:00
Raphael Michel
6d12b3780c Allow to hide all sold out items 2019-07-18 15:01:33 +02:00
Raphael Michel
a99616b1e0 API: Check-in response code for canceled 2019-07-18 15:01:33 +02:00
Martin Gross
a5ba7440fe Fix #1345 - Only enable payment button once Stripe Elements are ready 2019-07-16 15:41:37 +02:00
Raphael Michel
a02ea45dba Allow quotas to "close" when once full (#1344)
* Model

* Some UI

* API and logging

* Permission check

* Add tests

* Move option around
2019-07-16 14:02:27 +02:00
Raphael Michel
c1e2fb36ba Auto-expand variation description when variation is selected 2019-07-16 11:53:43 +02:00
Raphael Michel
b67c684969 Revert "Allow to show description of add-on product variations by default"
This reverts commit 8d674965d1.
2019-07-16 11:46:11 +02:00
Raphael Michel
dc42dbb837 Allow to use a selection for name titles 2019-07-16 10:23:43 +02:00
Raphael Michel
44ffc0685e Show date_to in PDF variable "event_date_range" regardless of event settings
Z#2349533
2019-07-16 09:31:40 +02:00
Raphael Michel
a79a156a28 Show preview of answered images 2019-07-16 09:31:36 +02:00
Raphael Michel
fb1f6c65af Display invoices as inline PDF
They are not user-controllable enough to cause any harm here
2019-07-16 09:16:33 +02:00
Raphael Michel
8d674965d1 Allow to show description of add-on product variations by default 2019-07-15 11:26:42 +02:00
Raphael Michel
020122b44f Fix missing words 2019-07-15 11:01:33 +02:00
Raphael Michel
f55fff6495 Update from Weblate (#1342)
Update from Weblate
2019-07-15 11:01:31 +02:00
Raphael Michel
08316129d3 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3165 of 3165 strings)

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

powered by weblate
2019-07-15 09:00:55 +00:00
Raphael Michel
a39563aa3e Translated on translate.pretix.eu (German)
Currently translated at 99.9% (3164 of 3165 strings)

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

powered by weblate
2019-07-15 09:00:54 +00:00
Raphael Michel
a3707a962b Fix problems with CartMixin on empty order
Fix PRETIXEU-18A
2019-07-15 10:46:38 +02:00
Raphael Michel
4bb8c3991e Fix badge-creation task
PRETIXEU-150
2019-07-15 10:46:27 +02:00
Raphael Michel
0d5c2f6329 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-07-15 10:31:30 +02:00
Raphael Michel
17c0cfb395 Add signal: order_split 2019-07-15 10:30:44 +02:00
Raphael Michel
e55f0cdf11 Retire make_testdata.py 2019-07-14 17:55:51 +02:00
Christian González
a2fbc376a5 typo in comment (#1339) 2019-07-14 16:59:08 +02:00
Raphael Michel
be310a4e47 Docs: Add agenda plugin to structure guide 2019-07-12 13:28:46 +02:00
Raphael Michel
35037c79cc Add signal validate_cart_addons 2019-07-12 13:06:29 +02:00
Raphael Michel
f8bb139651 AddOnsForm: Already validate min_count/max_count 2019-07-12 12:32:43 +02:00
Raphael Michel
77046136f2 asynctask.js: Hack to allow form validation 2019-07-12 12:23:34 +02:00
Raphael Michel
53a0d62d93 Allow dependent questions to depend on multiple values (#1336) 2019-07-11 13:32:45 +02:00
Raphael Michel
d994fc674a Do not CASCADE-delete vouchers when deleting items or quotas 2019-07-11 12:35:52 +02:00
Raphael Michel
f066ed01ff Show event meta data in backend list of events 2019-07-11 11:16:36 +02:00
Raphael Michel
fb66434fc9 Update from Weblate (#1335)
Update from Weblate
2019-07-11 10:37:46 +02:00
Vitor Piedras
3f9269f6e5 Translated on translate.pretix.eu (Portuguese (Brazil))
Currently translated at 15.9% (501 of 3152 strings)

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

powered by weblate
2019-07-11 06:56:27 +00:00
Raphael Michel
2a30a1a039 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3152 of 3152 strings)

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

powered by weblate
2019-07-11 06:56:27 +00:00
Raphael Michel
846f20692d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3152 of 3152 strings)

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

powered by weblate
2019-07-11 06:56:27 +00:00
Raphael Michel
2eb5adb6c1 Stripe: Improve exception handling
PRETIXEU-17Y
2019-07-11 08:56:01 +02:00
Raphael Michel
491753008d Introduce Item.show_quota_left 2019-07-10 16:08:21 +02:00
Raphael Michel
6d6cd3b7cf [SECURITY] Fix XSS in global admin mode 2019-07-10 14:52:58 +02:00
Raphael Michel
eaf6da7272 Protect against main javascript being loaded before translations 2019-07-10 14:31:49 +02:00
Raphael Michel
22ce7a388d Do not send notifications to disabled users 2019-07-10 09:00:41 +02:00
Raphael Michel
e687eee9f1 Widget: Allow voucher with itemless button 2019-07-09 18:55:10 +02:00
Raphael Michel
e7baca952b Fix voucher queryset (again) 2019-07-09 18:02:08 +02:00
Raphael Michel
eef713816e Sort keys in JSON payment metadata 2019-07-09 16:13:37 +02:00
Raphael Michel
5a03033255 Add utility to get IP address 2019-07-09 16:13:37 +02:00
Raphael Michel
59daeba477 Do not redirect to order.pay.complete for pending orders 2019-07-09 16:13:37 +02:00
Raphael Michel
c1a4b8d343 Payment provider API: Add payment argument to render_invoice_text and order_pending_mail_render 2019-07-09 16:13:37 +02:00
Raphael Michel
0ac98f5127 Use inspect instead of TypeError for backwards-compatible APIs 2019-07-09 16:13:37 +02:00
Raphael Michel
55d423af18 Widget: Allow to filter by attributes 2019-07-08 23:27:46 +02:00
Raphael Michel
285694955c Fix AttributeError 2019-07-08 18:25:31 +02:00
Raphael Michel
2352f3b811 Fix voucher validation in CartManager 2019-07-08 17:50:22 +02:00
Raphael Michel
08bfe13dc3 Re-add validation for hidden vouchers 2019-07-08 14:25:35 +02:00
Raphael Michel
ec522ed7e5 Tax list exporter as Excel 2019-07-08 14:25:22 +02:00
Raphael Michel
197ec84f05 Order overview: Allow to filter by date 2019-07-08 14:25:22 +02:00
Martin Gross
42af8b1602 Remove excessive chars in U2F_GET_API_VERSION_RESPONSE 2019-07-08 13:39:12 +02:00
Raphael Michel
f6a4c5271e Remove obsolete validation 2019-07-08 11:05:08 +02:00
Martin Gross
fb53beee2d Option to notify users when questions have been changed in backend 2019-07-08 10:23:32 +02:00
Raphael Michel
ca1c387a41 Allow quota-level vouchers for hidden products (#1123)
* Changes in checks

* Backwards-compatible implementation

* Add test

* Fix voucher bulk form
2019-07-07 13:36:04 +02:00
Raphael Michel
5180b5e48b Fix #1329 -- Fix image lightbox for products with variations 2019-07-05 16:58:39 +02:00
Raphael Michel
a5e94bf63f Protect against fee signal returning None 2019-07-05 14:33:43 +02:00
Raphael Michel
09ef7aac6e Subevent: Allow to pass empty mapping 2019-07-04 18:25:48 +02:00
Raphael Michel
d90510a1bd Fix incorrect headline 2019-07-04 17:59:25 +02:00
Raphael Michel
48790e7743 Fix incorrect header in documentation samples 2019-07-04 17:59:17 +02:00
Raphael Michel
cbeaf399df Update Stripe API 2019-07-04 11:08:05 +02:00
Raphael Michel
779a3698a8 Catch general HTTP errors during VAT validation 2019-07-04 10:39:41 +02:00
Raphael Michel
a5e2caf438 Consistently include other fees in percentual payment fee 2019-07-04 09:31:21 +02:00
Martin Gross
ce79769293 Fix overlooked Stripe-Tests, still using _token instead of _payment_method_id 2019-07-03 22:04:05 +02:00
Martin Gross
9fbb8fa781 Do not _handle_payment_intent() in Stripe's pending order view 2019-07-03 19:19:40 +02:00
Raphael Michel
83c551c1ba API: Correctly set default position IDs for orders 2019-07-03 16:46:03 +02:00
Raphael Michel
328cd9bdc5 Use shell_plus in shell_scoped 2019-07-03 14:32:07 +02:00
Raphael Michel
4ce7655958 Docs: Remove experimental note from order creation endpoint 2019-07-03 13:39:43 +02:00
Raphael Michel
bccc73f1dc Optimized command-line exports 2019-07-03 13:35:26 +02:00
Raphael Michel
5eeba88283 Stripe: Robust webhook recognition 2019-07-03 10:57:36 +02:00
Raphael Michel
4c2fe9fc20 Update from Weblate (#1326)
Update from Weblate
2019-07-02 12:41:13 +02:00
Maarten van den Berg
f2ba409b03 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3152 of 3152 strings)

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

powered by weblate
2019-07-02 10:37:14 +00:00
Maarten van den Berg
296c2b6e28 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (100 of 100 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl_Informal/

powered by weblate
2019-07-02 10:37:14 +00:00
Maarten van den Berg
ab27bcca42 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 99.9% (3151 of 3152 strings)

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

powered by weblate
2019-07-02 10:37:14 +00:00
Maarten van den Berg
b0a365a099 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3152 of 3152 strings)

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

powered by weblate
2019-07-02 10:37:14 +00:00
Maarten van den Berg
97fc095d20 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (100 of 100 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl/

powered by weblate
2019-07-02 10:37:14 +00:00
Maarten van den Berg
cfb1cd8fdb Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3152 of 3152 strings)

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

powered by weblate
2019-07-02 10:37:14 +00:00
Martin Gross
446cf68377 Stripe SCA (#1275)
* Stripe SCA
- Upgrade to latest Stripe API
- Deprecate Stripe Checkout for CC
- Migrate CC payments to Payment Intents

* Move SCA to its own view

* Handle CardErrors for PaymentIntents

* Abilty to handle charge webhooks with PaymentIntents

* Better handling of Stripe References

* Fix Stripe Tests

* Move SCA page into orderlayout; perform iFrame SCA

* Handle disputes and pi-webhooks better, fill more into ReferencedStripeObject

* Optionally pass prefetched PaymentIntent to handle-func

* Fix style

* Send message to window.parent not window.top (widget compatibility)

* More accurate loading message

* Show a cog on sca_return.html. On a good internet connection, you barely see it, but on a bad one…

* Robust error handling

* If it's a method and used like a method, let's actually call it like a method!

* Remove logging statement

* Fix JavaScript interference with other frame events

* Use 4:3 aspect ratio, but at least 600px

* Adjust to django_scopes
2019-07-02 12:37:07 +02:00
Raphael Michel
b727207e79 API: Fix query for check-in list status 2019-07-01 17:18:22 +02:00
Raphael Michel
fcc4170a4a Add shell_scoped command. Thanks @rixx! 2019-06-27 11:39:12 +02:00
Raphael Michel
c7f345e98e Allow to filter order list by variations 2019-06-26 14:27:02 +02:00
Raphael Michel
d30fbf4e6a Event front page: Show calendar by default when a month is selected 2019-06-25 13:02:38 +02:00
Raphael Michel
5326aa7486 Merge pull request #1325 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-06-25 11:33:19 +02:00
Raphael Michel
53147c0f0c Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3152 of 3152 strings)

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

powered by weblate
2019-06-25 09:32:44 +00:00
Raphael Michel
fe31318413 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3152 of 3152 strings)

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

powered by weblate
2019-06-25 09:32:43 +00:00
Raphael Michel
bb4821eeb5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-25 11:08:47 +02:00
Raphael Michel
003d958cc5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-25 11:04:17 +02:00
Raphael Michel
93089d87e3 Add support for reserved seating (#1228)
* Initial work on seating

* Add seat guids

* Add product_list_top

* CartAdd: Ignore item when a seat is passed

* Cart display

* product_list_top → render_seating_plan

* Render seating plan in voucher redemption

* Fix failing tests

* Add tests for extending cart positions with seats

* Add subevent_forms to docs

* Update schema, migrations

* Dealing with expired orders

* steps to order change

* Change order positions

* Allow to add seats

* tests for ocm

* Fix things after rebase

* Seating plans API

* Add more tests for cart behaviour

* Widget support

* Adjust widget tests

* Re-enable CSP

* Update schema

* Api: position.seat

* Add guid to word list

* API: (sub)event.seating_plan

* Vali fixes

* Fix api

* Fix reference in test

* Fix test for real
2019-06-25 11:00:03 +02:00
Raphael Michel
f79d17cb6a Navigation: Only show orders/vouchers with a search query 2019-06-24 11:41:36 +02:00
Raphael Michel
0e8db3181c OrderChangeManager: Allow to add positions to empty orders 2019-06-21 14:33:10 +02:00
Raphael Michel
23031642bd Fix crash when re-using logged emails
Fix PRETIXEU-16Q
2019-06-21 12:01:51 +02:00
Raphael Michel
93cca34eab PayPal: Add scopes decorator to oauth_return 2019-06-20 19:29:23 +02:00
Raphael Michel
e29c8a1708 Stripe: disable scopes for oauth return 2019-06-20 13:57:15 +02:00
Raphael Michel
acfec59abc Fix ineffective permission check in typeahead 2019-06-19 09:32:30 +02:00
Raphael Michel
7adf203863 Make order search search in used voucher codes 2019-06-19 09:17:46 +02:00
Raphael Michel
3c2de09216 Integrate orders and vouchers into navigation typeahead 2019-06-19 09:16:33 +02:00
Raphael Michel
26a96f107f Add signal quota_availability 2019-06-18 16:52:01 +02:00
Raphael Michel
819dd7eee6 Correctly show infinite quotas in backend 2019-06-18 16:29:36 +02:00
Raphael Michel
ccc662228c Force evaluation of template responses in frontend 2019-06-17 22:59:45 +02:00
Raphael Michel
99a2fde373 Voucher form: Move product above price mode 2019-06-17 22:56:51 +02:00
Raphael Michel
dda48d92c6 Update from Weblate (#1317)
Update from Weblate
2019-06-17 17:08:44 +02:00
Martin Gross
0a1429ed60 Add setting for enforcing 2FA (#1259)
* Add setting for enforcing 2FA

* Changes after code-review

* Add Test-Cases for Obligatory 2FA
2019-06-17 17:08:27 +02:00
Mattias Axell
8487a5446d Translated on translate.pretix.eu (Swedish)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-06-17 12:57:56 +00:00
Vlad
64833c0bab Translated on translate.pretix.eu (Russian)
Currently translated at 63.6% (63 of 99 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/ru/

powered by weblate
2019-06-17 12:57:56 +00:00
Vlad
3f40525af5 Translated on translate.pretix.eu (Russian)
Currently translated at 0.6% (19 of 3125 strings)

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

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
7b6b3b1348 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (99 of 99 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl_Informal/

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
0b9f4cd739 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (99 of 99 strings)

Translation: pretix/pretix (frontend)
Translate-URL: https://translate.pretix.eu/projects/pretix/pretix-js/nl/

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
885eefbcb0 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3125 of 3125 strings)

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

powered by weblate
2019-06-17 12:57:56 +00:00
Maarten van den Berg
573757e2bf Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3125 of 3125 strings)

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

powered by weblate
2019-06-17 12:57:56 +00:00
Raphael Michel
c5a2bd35b7 Devices list: Correctly use revoked parameter 2019-06-17 14:41:23 +02:00
Raphael Michel
4b65b94bd5 Disable scopes for all unique ID generation 2019-06-17 14:05:05 +02:00
Raphael Michel
d716f7e014 Fix scoping issue in mail_send_task 2019-06-17 11:07:05 +02:00
Raphael Michel
d85ddb5bda Integrate django-scopes (#1319)
* Install django-scopes

* Fix tests.api

* Update tasks and cronjobs

* Fix remaining tests

* Remove unused import

* Fix tests after rebase

* Disable scopes for get_Events_with_any_permission

* Disable scopes for a management command
2019-06-17 10:46:55 +02:00
Martin Gross
b1db5dbb3e Decrement voucher usage counter when deleting testmode orders (#1321)
* Decrement voucher usage counter when deleting testmode orders

* Only decrement voucher usage counter for uncancelled orders and on uncancelled positions

* Have the tests actually test something
2019-06-14 12:41:07 +02:00
Raphael Michel
fed389b990 Remove plugins task from travis 2019-06-14 12:21:34 +02:00
Raphael Michel
1ce613ff89 Add new signal nav_item 2019-06-14 12:20:27 +02:00
Raphael Michel
44bef85b66 Require recent django-localflavor 2019-06-12 12:49:22 +02:00
Raphael Michel
49c4acefd0 Fix critical error in previous commit 2019-06-10 17:18:08 +02:00
Raphael Michel
61e111742d Avoid unneccesary logs in some highly-used API endpoints 2019-06-09 23:54:48 +02:00
Raphael Michel
b2274039b3 Sendmail: Fix using old log entries 2019-06-06 11:40:21 +02:00
Raphael Michel
4913190730 Update from Weblate (#1312)
Update from Weblate
2019-06-06 11:25:58 +02:00
Raphael Michel
276a087fdb Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3125 of 3125 strings)

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

powered by weblate
2019-06-06 09:24:50 +00:00
Raphael Michel
3639f2cea1 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3125 of 3125 strings)

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

powered by weblate
2019-06-06 09:24:49 +00:00
Raphael Michel
692e9c38f1 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-06 11:11:46 +02:00
Raphael Michel
dd4075b2cc Clarify UX around subevent selection 2019-06-06 11:10:51 +02:00
Raphael Michel
b549cb451a Fix invalid signature 2019-06-05 16:44:49 +02:00
Raphael Michel
576132b2d0 Bump to 2.9.0.dev0 2019-06-05 16:28:49 +02:00
Raphael Michel
e0c432d014 [SECURITY] Do not allow to enumerate organizers 2019-06-05 16:27:21 +02:00
359 changed files with 80886 additions and 61280 deletions

View File

@@ -25,8 +25,6 @@ matrix:
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.5 - python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.7
env: JOB=plugins
- python: 3.7 - python: 3.7
env: JOB=doc-spelling env: JOB=doc-spelling
- python: 3.7 - python: 3.7

View File

@@ -78,6 +78,15 @@ Example::
Enables or disables nagging staff users for leaving comments on their sessions for auditability. Enables or disables nagging staff users for leaving comments on their sessions for auditability.
Defaults to ``off``. Defaults to ``off``.
``obligatory_2fa``
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
Defaults to ``False``
``trust_x_forwarded_for``
Specifies whether the ``X-Forwarded-For`` header can be trusted. Only set to ``on`` if you have a reverse
proxy that actively removes and re-adds the header to make sure the correct client IP is the first value.
Defaults to ``off``.
Locale settings Locale settings
--------------- ---------------

View File

@@ -36,12 +36,20 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field ├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types) ├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.17 .. versionchanged:: 1.17
This resource has been added. This resource has been added.
.. versionchanged:: 3.0
This ``seat`` attribute has been added.
Cart position endpoints Cart position endpoints
----------------------- -----------------------
@@ -87,6 +95,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z", "datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z", "expires": "2018-06-11T10:00:00Z",
"includes_tax": true, "includes_tax": true,
"seat": null,
"answers": [] "answers": []
} }
] ]
@@ -132,6 +141,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z", "datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z", "expires": "2018-06-11T10:00:00Z",
"includes_tax": true, "includes_tax": true,
"seat": null,
"answers": [] "answers": []
} }
@@ -178,6 +188,7 @@ Cart position endpoints
* ``item`` * ``item``
* ``variation`` (optional) * ``variation`` (optional)
* ``price`` * ``price``
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` (optional) * ``attendee_name`` **or** ``attendee_name_parts`` (optional)
* ``attendee_email`` (optional) * ``attendee_email`` (optional)
* ``subevent`` (optional) * ``subevent`` (optional)
@@ -196,7 +207,7 @@ Cart position endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"item": 1, "item": 1,

View File

@@ -131,7 +131,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/categories/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/categories/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": {"en": "Tickets"}, "name": {"en": "Tickets"},

View File

@@ -209,7 +209,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": "VIP entry", "name": "VIP entry",
@@ -396,6 +396,7 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
@@ -505,6 +506,7 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
@@ -546,6 +548,8 @@ Order position endpoints
you do not implement question handling in your user interface, you **must** you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults set this to ``false``. In that case, questions will just be ignored. Defaults
to ``true``. to ``true``.
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
returned. Otherwise, canceled orders will return ``unpaid``.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used. :<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required :<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
questions that have not been filled. Defaults to ``false``. questions that have not been filled. Defaults to ``false``.
@@ -574,6 +578,7 @@ Order position endpoints
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA", "nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"datetime": null, "datetime": null,
"questions_supported": true, "questions_supported": true,
"canceled_supported": true,
"answers": { "answers": {
"4": "XS" "4": "XS"
} }
@@ -657,7 +662,9 @@ Order position endpoints
Possible error reasons: Possible error reasons:
* ``unpaid`` - Ticket is not paid for or has been refunded * ``unpaid`` - Ticket is not paid for
* ``canceled`` Ticket is canceled or expired. This reason is only sent when your request sets
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
* ``already_redeemed`` - Ticket already has been redeemed * ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device * ``product`` - Tickets with this product may not be scanned at this device

View File

@@ -27,9 +27,13 @@ presale_end datetime The date at whi
location multi-lingual string The event location (or ``null``) location multi-lingual string The event location (or ``null``)
has_subevents boolean ``true`` if the event series feature is active for this has_subevents boolean ``true`` if the event series feature is active for this
event. Cannot change after event is created. event. Cannot change after event is created.
meta_data dict Values set for organizer-specific meta data parameters. meta_data object Values set for organizer-specific meta data parameters.
plugins list A list of package names of the enabled plugins for this plugins list A list of package names of the enabled plugins for this
event. event.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -54,6 +58,10 @@ plugins list A list of packa
When cloning events, the ``testmode`` attribute will now be cloned, too. When cloning events, the ``testmode`` attribute will now be cloned, too.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints Endpoints
--------- ---------
@@ -99,6 +107,8 @@ Endpoints
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"seating_plan": null,
"seat_category_mapping": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
"pretix.plugins.stripe" "pretix.plugins.stripe"
@@ -160,6 +170,8 @@ Endpoints
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer" "pretix.plugins.banktransfer"
@@ -191,7 +203,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": {"en": "Sample Conference"}, "name": {"en": "Sample Conference"},
@@ -205,6 +217,8 @@ Endpoints
"is_public": false, "is_public": false,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
@@ -235,6 +249,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
@@ -269,7 +285,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/clone/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": {"en": "Sample Conference"}, "name": {"en": "Sample Conference"},
@@ -284,6 +300,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false, "has_subevents": false,
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
@@ -314,6 +332,8 @@ Endpoints
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.stripe", "pretix.plugins.stripe",
@@ -342,7 +362,7 @@ Endpoints
PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1 PATCH /api/v1/organizers/bigevents/events/sampleconf/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"plugins": [ "plugins": [
@@ -375,6 +395,8 @@ Endpoints
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"has_subevents": false, "has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {}, "meta_data": {},
"plugins": [ "plugins": [
"pretix.plugins.banktransfer", "pretix.plugins.banktransfer",

View File

@@ -23,4 +23,5 @@ Resources and endpoints
waitinglist waitinglist
carts carts
webhooks webhooks
seatingplans
billing_invoices billing_invoices

View File

@@ -134,7 +134,7 @@ Endpoints
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ HTTP/1.1 POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/addons/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"addon_category": 1, "addon_category": 1,

View File

@@ -134,7 +134,7 @@ Endpoints
POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ HTTP/1.1 POST /api/v1/organizers/(organizer)/events/(event)/items/(item)/bundles/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"bundled_item": 3, "bundled_item": 3,

View File

@@ -152,7 +152,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/items/1/variations/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"value": {"en": "Student"}, "value": {"en": "Student"},

View File

@@ -44,6 +44,9 @@ available_from datetime The first date
(or ``null``). (or ``null``).
available_until datetime The last date time at which this item can be bought available_until datetime The last date time at which this item can be bought
(or ``null``). (or ``null``).
hidden_if_available integer The internal ID of a quota object, or ``null``. If
set, this item won't be shown publicly as long as this
quota is available.
require_voucher boolean If ``true``, this item can only be bought using a require_voucher boolean If ``true``, this item can only be bought using a
voucher that is specifically assigned to this item. voucher that is specifically assigned to this item.
hide_without_voucher boolean If ``true``, this item is only shown during the voucher hide_without_voucher boolean If ``true``, this item is only shown during the voucher
@@ -72,6 +75,10 @@ generate_tickets boolean If ``false``, t
non-admission or add-on product, regardless of event non-admission or add-on product, regardless of event
settings. If this is ``null``, regular ticketing settings. If this is ``null``, regular ticketing
rules apply. rules apply.
allow_waitinglist boolean If ``false``, no waiting list will be shown for this
product when it is sold out.
show_quota_left boolean Publicly show how many tickets are still available.
If this is ``null``, the event default is used.
has_variations boolean Shows whether or not this item has variations. has_variations boolean Shows whether or not this item has variations.
variations list of objects A list with one object for each variation of this item. variations list of objects A list with one object for each variation of this item.
Can be empty. Only writable during creation, Can be empty. Only writable during creation,
@@ -142,6 +149,10 @@ bundles list of objects Definition of b
The ``bundles`` and ``require_bundling`` attributes have been added. The ``bundles`` and ``require_bundling`` attributes have been added.
.. versionchanged:: 3.0
The ``show_quota_left``, ``allow_waitinglist``, and ``hidden_if_available`` attributes have been added.
Notes Notes
----- -----
@@ -199,6 +210,7 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
@@ -207,6 +219,8 @@ Endpoints
"checkin_attention": false, "checkin_attention": false,
"has_variations": false, "has_variations": false,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"require_approval": false, "require_approval": false,
"require_bundling": false, "require_bundling": false,
"variations": [ "variations": [
@@ -290,10 +304,13 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
@@ -342,7 +359,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/items/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"id": 1, "id": 1,
@@ -362,10 +379,13 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"checkin_attention": false, "checkin_attention": false,
@@ -421,12 +441,15 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": true, "has_variations": true,
"require_approval": false, "require_approval": false,
@@ -512,9 +535,12 @@ Endpoints
"picture": null, "picture": null,
"available_from": null, "available_from": null,
"available_until": null, "available_until": null,
"hidden_if_available": null,
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"generate_tickets": null, "generate_tickets": null,
"allow_waitinglist": true,
"show_quota_left": null,
"allow_cancel": true, "allow_cancel": true,
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,

View File

@@ -176,6 +176,10 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field ├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types) ├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
pdf_data object Data object required for ticket PDF generation. By default, pdf_data object Data object required for ticket PDF generation. By default,
this field is missing. It will be added only if you add the this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request. ``pdf_data=true`` query parameter to your request.
@@ -197,6 +201,10 @@ pdf_data object Data object req
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added. The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
.. versionchanged:: 3.0
The attribute ``seat`` has been added.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -328,6 +336,7 @@ List of all orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
@@ -470,6 +479,7 @@ Fetching individual orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
@@ -688,8 +698,6 @@ Creating orders
Creates a new order. Creates a new order.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning:: .. warning::
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend, This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend,
@@ -737,7 +745,7 @@ Creating orders
then call the ``mark_paid`` API method. then call the ``mark_paid`` API method.
* ``testmode`` (optional) Defaults to ``false`` * ``testmode`` (optional) Defaults to ``false``
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the * ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas that become free by this operation will be credited to your order order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
creation. creation.
* ``email`` * ``email``
* ``locale`` * ``locale``
@@ -771,6 +779,7 @@ Creating orders
* ``item`` * ``item``
* ``variation`` * ``variation``
* ``price`` * ``price``
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` * ``attendee_name`` **or** ``attendee_name_parts``
* ``attendee_email`` * ``attendee_email``
* ``secret`` (optional) * ``secret`` (optional)
@@ -1287,6 +1296,7 @@ List of all order positions
"tax_value": "0.00", "tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"checkins": [ "checkins": [
@@ -1389,6 +1399,7 @@ Fetching individual positions
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,

View File

@@ -54,11 +54,12 @@ dependency_question integer Internal ID of
this attribute is set to the value given in this attribute is set to the value given in
``dependency_value``. This cannot be combined with ``dependency_value``. This cannot be combined with
``ask_during_checkin``. ``ask_during_checkin``.
dependency_value string The value ``dependency_question`` needs to be set to. dependency_values list of strings If ``dependency_question`` is set to a boolean
If ``dependency_question`` is set to a boolean question, this should be ``["True"]`` or ``["False"]``.
question, this should be ``"true"`` or ``"false"``. Otherwise, it should be a list of ``identifier`` values
Otherwise, it should be the ``identifier`` of a of question options.
question option. dependency_value string An old version of ``dependency_values`` that only allows
for one value. **Deprecated.**
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.12 .. versionchanged:: 1.12
@@ -75,6 +76,10 @@ dependency_value string The value ``dep
The attribute ``hidden`` and the question type ``CC`` have been added. The attribute ``hidden`` and the question type ``CC`` have been added.
.. versionchanged:: 3.0
The attribute ``dependency_values`` has been added.
Endpoints Endpoints
--------- ---------
@@ -120,6 +125,7 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -188,6 +194,7 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -228,7 +235,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"question": {"en": "T-Shirt size"}, "question": {"en": "T-Shirt size"},
@@ -239,7 +246,7 @@ Endpoints
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_values": [],
"options": [ "options": [
{ {
"answer": {"en": "S"} "answer": {"en": "S"}
@@ -274,6 +281,7 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -346,6 +354,7 @@ Endpoints
"hidden": false, "hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,

View File

@@ -20,12 +20,22 @@ size integer The size of the
items list of integers List of item IDs this quota acts on. items list of integers List of item IDs this quota acts on.
variations list of integers List of item variation IDs this quota acts on. variations list of integers List of item variation IDs this quota acts on.
subevent integer ID of the date inside an event series this quota belongs to (or ``null``). subevent integer ID of the date inside an event series this quota belongs to (or ``null``).
close_when_sold_out boolean If ``true``, the quota will "close" as soon as it is
sold out once. Even if tickets become available again,
they will not be sold unless the quota is set to open
again.
closed boolean Whether the quota is currently closed (see above
field).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.10 .. versionchanged:: 1.10
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attributes ``close_when_sold_out`` and ``closed`` have been added.
Endpoints Endpoints
--------- ---------
@@ -61,7 +71,9 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
] ]
} }
@@ -102,7 +114,9 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -123,14 +137,16 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/quotas/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/quotas/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": "Ticket Quota", "name": "Ticket Quota",
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
**Example response**: **Example response**:
@@ -147,7 +163,9 @@ Endpoints
"size": 200, "size": 200,
"items": [1, 2], "items": [1, 2],
"variations": [1, 4, 5, 7], "variations": [1, 4, 5, 7],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a quota for :param organizer: The ``slug`` field of the organizer of the event/item to create a quota for
@@ -200,7 +218,9 @@ Endpoints
1, 1,
2 2
], ],
"subevent": null "subevent": null,
"close_when_sold_out": false,
"closed": false
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify

View File

@@ -0,0 +1,209 @@
.. _`rest-seatingplans`:
Seating plans
=============
Resource description
--------------------
The seating plan resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the plan
name string Human-readable name of the plan
layout object JSON representation of the seating plan. These
representations follow a JSON schema that currently
still evolves. The version in use can be found `here`_.
===================================== ========================== =======================================================
.. versionchanged:: 3.0
This endpoint has been added.
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/
Returns a list of all seating plans within a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 2,
"name": "Main plan",
"layout": { … }
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Returns information on one plan, identified by its ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 2,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to fetch
:param id: The ``id`` field of the seating plan to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to view this resource.
.. http:post:: /api/v1/organizers/(organizer)/seatingplans/
Creates a new seating plan
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/seatingplans/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"name": "Main plan",
"layout": { … }
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"id": 3,
"name": "Main plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to create a seating plan for
:statuscode 201: no error
:statuscode 400: The seating plan could not be created due to invalid submitted data.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to create this resource.
.. http:patch:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Update a plan. You can also use ``PUT`` instead of ``PATCH``. With ``PUT``, you have to provide all fields of
the resource, other fields will be reset to default. With ``PATCH``, you only need to provide the fields that you
want to change.
You can change all fields of the resource except the ``id`` field. **You can not change a plan while it is in use for
any events.**
**Example request**:
.. sourcecode:: http
PATCH /api/v1/organizers/bigevents/seatingplans/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Content-Length: 94
{
"name": "Old plan"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"id": 1,
"name": "Old plan",
"layout": { … }
}
:param organizer: The ``slug`` field of the organizer to modify
:param id: The ``id`` field of the plan to modify
:statuscode 200: no error
:statuscode 400: The plan could not be modified due to invalid submitted data
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to change this resource **or** the plan is currently in use.
.. http:delete:: /api/v1/organizers/(organizer)/seatingplans/(id)/
Delete a plan. You can not delete plans which are currently in use by any events.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/seatingplans/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 id: The ``id`` field of the plan to delete
:statuscode 204: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer does not exist **or** you have no permission to delete this resource **or** the plan is currently in use.
.. _here: https://github.com/pretix/pretix/blob/master/src/pretix/static/seating/seating-plan.schema.json

View File

@@ -36,7 +36,11 @@ variation_price_overrides list of objects List of variati
the default price the default price
├ variation integer The internal variation ID ├ variation integer The internal variation ID
└ price money (string) The price or ``null`` for the default price └ price money (string) The price or ``null`` for the default price
meta_data dict Values set for organizer-specific meta data parameters. meta_data object Values set for organizer-specific meta data parameters.
seating_plan integer If reserved seating is in use, the ID of a seating
plan. Otherwise ``null``.
seat_category_mapping object An object mapping categories of the seating plan
(strings) to items in the event (integers or ``null``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.7 .. versionchanged:: 1.7
@@ -54,6 +58,10 @@ meta_data dict Values set for
The attribute ``is_public`` has been added. The attribute ``is_public`` has been added.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints Endpoints
--------- ---------
@@ -93,6 +101,8 @@ Endpoints
"date_admission": null, "date_admission": null,
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null, "location": null,
"item_price_overrides": [ "item_price_overrides": [
{ {
@@ -130,7 +140,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/subevents/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": {"en": "First Sample Conference"}, "name": {"en": "First Sample Conference"},
@@ -142,6 +152,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -172,6 +184,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -223,6 +237,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -255,7 +271,7 @@ Endpoints
PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1 PATCH /api/v1/organizers/bigevents/events/sampleconf/subevents/1/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": {"en": "New Subevent Name"}, "name": {"en": "New Subevent Name"},
@@ -287,6 +303,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,
@@ -371,6 +389,8 @@ Endpoints
"presale_start": null, "presale_start": null,
"presale_end": null, "presale_end": null,
"location": null, "location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [ "item_price_overrides": [
{ {
"item": 2, "item": 2,

View File

@@ -41,6 +41,7 @@ quota integer An ID of a quot
tag string A string that is used for grouping vouchers tag string A string that is used for grouping vouchers
comment string An internal comment on the voucher comment string An internal comment on the voucher
subevent integer ID of the date inside an event series this voucher belongs to (or ``null``). subevent integer ID of the date inside an event series this voucher belongs to (or ``null``).
show_hidden_items boolean Only if set to ``true``, this voucher allows to buy products with the property ``hide_without_voucher``. Defaults to ``true``.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -48,6 +49,10 @@ subevent integer ID of the date
The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added. The write operations ``POST``, ``PATCH``, ``PUT``, and ``DELETE`` have been added.
.. versionchanged:: 3.0
The attribute ``show_hidden_items`` has been added.
Endpoints Endpoints
--------- ---------

View File

@@ -137,7 +137,7 @@ Endpoints
POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1 POST /api/v1/organizers/bigevents/webhooks/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"enabled": true, "enabled": true,

View File

@@ -101,9 +101,12 @@ The template is passed the following context variables:
The ``Event`` object The ``Event`` object
``signature`` (optional, only if configured) ``signature`` (optional, only if configured)
The body as markdown (render with ``{{ signature|safe }}``) The signature with event organizer contact details as markdown (render with ``{{ signature|safe }}``)
``order`` (optional, only if applicable) ``order`` (optional, only if applicable)
The ``Order`` object The ``Order`` object
``position`` (optional, only if applicable)
The ``OrderPosition`` object
.. _inlinestyler: https://pypi.org/project/inlinestyler/ .. _inlinestyler: https://pypi.org/project/inlinestyler/

View File

@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types, :members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels, register_global_settings item_copy_data, register_sales_channels, register_global_settings, quota_availability
Order events Order events
"""""""""""" """"""""""""
@@ -20,13 +20,17 @@ Order events
There are multiple signals that will be sent out in the ordering cycle: There are multiple signals that will be sent out in the ordering cycle:
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: validate_cart, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download :members: validate_cart, validate_cart_addons, validate_order, order_fee_calculation, order_paid, order_placed, order_canceled, order_expired, order_modified, order_changed, order_approved, order_denied, order_fee_type_name, allow_ticket_download, order_split
Frontend Frontend
-------- --------
.. automodule:: pretix.presale.signals .. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, checkout_flow_steps, order_info, order_meta_from_request, position_info :members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info
.. automodule:: pretix.presale.signals
:members: order_info, order_meta_from_request
Request flow Request flow
"""""""""""" """"""""""""
@@ -45,7 +49,7 @@ Backend
.. automodule:: pretix.control.signals .. automodule:: pretix.control.signals
:members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, :members: nav_event, html_head, html_page_start, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings,
order_info, event_settings_widget, oauth_application_registered, order_position_buttons order_info, event_settings_widget, oauth_application_registered, order_position_buttons, subevent_forms, item_formsets
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals

View File

@@ -65,9 +65,7 @@ Then, create the local database::
python manage.py migrate python manage.py migrate
A first user with username ``admin@localhost`` and password ``admin`` will be automatically A first user with username ``admin@localhost`` and password ``admin`` will be automatically
created. If you want to generate more test data, run:: created.
python make_testdata.py
If you want to see pretix in a different language than English, you have to compile our language If you want to see pretix in a different language than English, you have to compile our language
files:: files::

View File

@@ -36,10 +36,13 @@ eu
filename filename
filesystem filesystem
fontawesome fontawesome
formset
formsets
frontend frontend
frontpage frontpage
gettext gettext
gunicorn gunicorn
guid
hardcoded hardcoded
hostname hostname
idempotency idempotency
@@ -134,6 +137,7 @@ versa
versioning versioning
viewset viewset
viewsets viewsets
waitinglist
webhook webhook
webhooks webhooks
webserver webserver

View File

@@ -45,8 +45,8 @@ In addition, you will need quotas. If you do not care how many of your tickets a
If you want to limit the number of student tickets to 50 to ensure a certain minimum revenue, but do not want to limit the number of regular tickets artificially, we suggest you to create the same quota of 200 that is linked to both products, and then create a **second quota** of 50 that is only linked to the student ticket. This way, the system will reduce both quotas whenever a student ticket is sold and only the larger quota when a regular ticket is sold. If you want to limit the number of student tickets to 50 to ensure a certain minimum revenue, but do not want to limit the number of regular tickets artificially, we suggest you to create the same quota of 200 that is linked to both products, and then create a **second quota** of 50 that is only linked to the student ticket. This way, the system will reduce both quotas whenever a student ticket is sold and only the larger quota when a regular ticket is sold.
Use case: Early-bird tiers Use case: Early-bird tiers based on dates
-------------------------- -----------------------------------------
Let's say you run a conference that has the following pricing scheme: Let's say you run a conference that has the following pricing scheme:
@@ -58,9 +58,53 @@ Of course, you could just set up one product and change its price at the given d
Create three products (e.g. "super early bird", "early bird", "regular ticket") with the respective prices and one shared quota of your total event capacity. Then, set the **available from** and **available until** configuration fields of the products to automatically turn them on and off based on the current date. Create three products (e.g. "super early bird", "early bird", "regular ticket") with the respective prices and one shared quota of your total event capacity. Then, set the **available from** and **available until** configuration fields of the products to automatically turn them on and off based on the current date.
.. note:: Use case: Early-bird tiers based on ticket numbers
--------------------------------------------------
pretix currently can't do early-bird tiers based on **ticket number** instead of time. We're planning this feature for later in 2019. For now, you'll need to monitor that manually. Let's say you run a conference with 400 tickets that has the following pricing scheme:
* First 100 tickets ("super early bird"): € 450
* Next 100 tickets ("early bird"): € 550
* Remaining tickets ("regular"): € 650
First of all, create three products:
* "Super early bird ticket"
* "Early bird ticket"
* "Regular ticket"
Then, create three quotas:
* "Super early bird" with a **size of 100** and the "Super early bird ticket" product selected. At "Advanced options",
select the box "Close this quota permanently once it is sold out".
* "Early bird and lower" with a **size of 200** and both of the "Super early bird ticket" and "Early bird ticket"
products selected. At "Advanced options", select the box "Close this quota permanently once it is sold out".
* "All participants" with a **size of 400**, all three products selected and **no additional options**.
Next, modify the product "Regular ticket". In the section "Availability", you should look for the option "Only show
after sellout of" and select your quota "Early bird and lower". Do the same for the "Early bird ticket" with the quota
"Super early bird ticket".
This will ensure the following things:
* Each ticket level is only visible after the previous level is sold out.
* As soon as one level is really sold out, it's not coming back, because the quota "closes", i.e. locks in place.
* By creating a total quota of 400 with all tickets included, you can still make sure to sell the maximum number of
tickets, even if e.g. early-bird tickets are canceled.
Optionally, if you want to hide the early bird prices once they are sold out, go to "Settings", then "Display" and
select "Hide all products that are sold out". Of course, it might be a nice idea to keep showing the prices to remind
people to buy earlier next time ;)
Please note that there might be short time intervals where the prices switch back and forth: When the last early bird
tickets are in someone's cart (but not yet sold!), the early bird tickets will show as "Reserved" and the regular
tickets start showing up. However, if the customers holding the reservations do not complete their order,
the early bird tickets will become available again. This is not avoidable if we want to prevent malicious users
from blocking all the cheap tickets without an actual sale happening.
Use case: Up-selling of ticket extras Use case: Up-selling of ticket extras
------------------------------------- -------------------------------------
@@ -85,8 +129,14 @@ Use case: Conference with workshops
When running a conference, you might also organize a number of workshops with smaller capacity. To be able to plan, it would be great to know which workshops an attendee plans to attend. When running a conference, you might also organize a number of workshops with smaller capacity. To be able to plan, it would be great to know which workshops an attendee plans to attend.
Option A: Questions
"""""""""""""""""""
Your first and simplest option is to just create a multiple-choice question. This has the upside of making it easy for users to change their mind later on, but will not allow you to restrict the number of attendees signing up for a given workshop or even charge extra for a given workshop. Your first and simplest option is to just create a multiple-choice question. This has the upside of making it easy for users to change their mind later on, but will not allow you to restrict the number of attendees signing up for a given workshop or even charge extra for a given workshop.
Option B: Add-on products with fixed time slots
"""""""""""""""""""""""""""""""""""""""""""""""
The usually better option is to go with add-on products. Let's take for example the following conference schedule, in which the lecture can be attended by anyone, but the workshops only have space for 20 persons each: The usually better option is to go with add-on products. Let's take for example the following conference schedule, in which the lecture can be attended by anyone, but the workshops only have space for 20 persons each:
==================== =================================== =================================== ==================== =================================== ===================================
@@ -117,6 +167,42 @@ Assuming you already created one or more products for your general conference ad
* One add-on configuration on your base product that allows users to choose between 0 and 2 products from the category "Workshops" * One add-on configuration on your base product that allows users to choose between 0 and 2 products from the category "Workshops"
Option C: Add-on products with variable time slots
""""""""""""""""""""""""""""""""""""""""""""""""""
The above option only works if your conference uses fixed time slots and every workshop uses exactly one time slot. If
your schedule looks like this, it's not going to work great:
+-------------+------------+-----------+
| Time | Room A | Room B |
+=============+============+===========+
| 09:00-11:00 | Talk 1 | Long |
+-------------+------------+ Workshop 1|
| 11:00-13:00 | Talk 2 | |
+-------------+------------+-----------+
| 14:00-16:00 | Long | Talk 3 |
+-------------+ workshop 2 +-----------+
| 16:00-18:00 | | Talk 4 |
+-------------+------------+-----------+
In this case, we recommend that you go to *Settings*, then *Plugins* and activate the plugin **Agenda constraints**.
Then, create a product (without variations) for every single part that should be bookable (talks 1-4 and long workshops
1 and 2) as well as appropriate quotas for each of them.
All of these products should be part of the same category. In your base product (e.g. your conference ticket), you
can then create an add-on product configuration allowing users to add products from this category.
If you edit these products, you will be able to enter the "Start date" and "End date" of the talk or workshop close
to the bottom of the page. If you fill in these values, pretix will automatically ensure no overlapping talks are
booked.
.. note::
This option is currently only available on pretix Hosted. If you are interested in using it with pretix Enterprise,
please contact sales@pretix.eu.
Use case: Discounted packages Use case: Discounted packages
----------------------------- -----------------------------

View File

@@ -143,6 +143,11 @@ You can see an example here:
</div> </div>
</noscript> </noscript>
You can filter events by meta data attributes. You can create those attributes in your order profile and set their values in both event and series date
settings. For example, if you set up a meta data property called "Promoted" that you set to "Yes" on some events, you can pass a filter like this::
<pretix-widget event="https://pretix.eu/demo/series/" style="list" filter="attr[Promoted]=Yes"></pretix-widget>
pretix Button pretix Button
------------- -------------

View File

@@ -1,71 +0,0 @@
#!/usr/bin/env python
import os
import sys
from datetime import datetime
os.environ.setdefault("DJANGO_SETTINGS_MODULE", "pretix.settings")
import django
django.setup()
from pretix.base.models import * # NOQA
from django.utils.timezone import now
if Organizer.objects.exists():
print("There already is data in your DB!")
sys.exit(0)
user = User.objects.get_or_create(
email='admin@localhost',
)[0]
user.set_password('admin')
user.save()
organizer = Organizer.objects.create(
name='BigEvents LLC', slug='bigevents'
)
year = now().year + 1
event = Event.objects.create(
organizer=organizer, name='Demo Conference {}'.format(year),
slug=year, currency='EUR', live=True,
date_from=datetime(year, 9, 4, 17, 0, 0),
date_to=datetime(year, 9, 6, 17, 0, 0),
)
t = Team.objects.get_or_create(
organizer=organizer, name='Admin Team',
all_events=True, can_create_events=True, can_change_teams=True,
can_change_organizer_settings=True, can_change_event_settings=True, can_change_items=True,
can_view_orders=True, can_change_orders=True, can_view_vouchers=True, can_change_vouchers=True
)
t[0].members.add(user)
cat_tickets = ItemCategory.objects.create(
event=event, name='Tickets'
)
cat_merch = ItemCategory.objects.create(
event=event, name='Merchandise'
)
question = Question.objects.create(
event=event, question='Age',
type=Question.TYPE_NUMBER, required=False
)
tr19 = event.tax_rules.create(rate=19)
item_ticket = Item.objects.create(
event=event, category=cat_tickets, name='Ticket',
default_price=23, tax_rule=tr19, admission=True
)
item_ticket.questions.add(question)
item_shirt = Item.objects.create(
event=event, category=cat_merch, name='T-Shirt',
default_price=15, tax_rule=tr19
)
var_s = ItemVariation.objects.create(item=item_shirt, value='S')
var_m = ItemVariation.objects.create(item=item_shirt, value='M')
var_l = ItemVariation.objects.create(item=item_shirt, value='L')
ticket_quota = Quota.objects.create(
event=event, name='Ticket quota', size=400,
)
ticket_quota.items.add(item_ticket)
ticket_shirts = Quota.objects.create(
event=event, name='Shirt quota', size=200,
)
ticket_quota.items.add(item_shirt)
ticket_quota.variations.add(var_s, var_m, var_l)

View File

@@ -1 +1 @@
__version__ = "2.8.0" __version__ = "3.0.1"

View File

@@ -1,4 +1,5 @@
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django_scopes import scopes_disabled
from rest_framework import exceptions from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication from rest_framework.authentication import TokenAuthentication
@@ -12,7 +13,8 @@ class DeviceTokenAuthentication(TokenAuthentication):
def authenticate_credentials(self, key): def authenticate_credentials(self, key):
model = self.get_model() model = self.get_model()
try: try:
device = model.objects.select_related('organizer').get(api_token=key) with scopes_disabled():
device = model.objects.select_related('organizer').get(api_token=key)
except model.DoesNotExist: except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.') raise exceptions.AuthenticationFailed('Invalid token.')

View File

@@ -3,7 +3,7 @@ from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.base.models import Device, Event, User from pretix.base.models import Device, Event, User
from pretix.base.models.auth import SuperuserPermissionSet from pretix.base.models.auth import SuperuserPermissionSet
from pretix.base.models.organizer import Organizer, TeamAPIToken from pretix.base.models.organizer import TeamAPIToken
from pretix.helpers.security import ( from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid, SessionInvalid, SessionReauthRequired, assert_session_valid,
) )
@@ -50,9 +50,6 @@ class EventPermission(BasePermission):
return False return False
elif 'organizer' in request.resolver_match.kwargs: elif 'organizer' in request.resolver_match.kwargs:
request.organizer = Organizer.objects.filter(
slug=request.resolver_match.kwargs['organizer'],
).first()
if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request): if not request.organizer or not perm_holder.has_organizer_permission(request.organizer, request=request):
return False return False
if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key): if isinstance(perm_holder, User) and perm_holder.has_active_staff_session(request.session.session_key):

View File

@@ -4,10 +4,13 @@ from hashlib import sha1
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django.http import HttpRequest, HttpResponse, JsonResponse from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import resolve
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scope
from rest_framework import status from rest_framework import status
from pretix.api.models import ApiCall from pretix.api.models import ApiCall
from pretix.base.models import Organizer
class IdempotencyMiddleware: class IdempotencyMiddleware:
@@ -89,3 +92,21 @@ class IdempotencyMiddleware:
for k, v in json.loads(call.response_headers).values(): for k, v in json.loads(call.response_headers).values():
r[k] = v r[k] = v
return r return r
class ApiScopeMiddleware:
def __init__(self, get_response):
self.get_response = get_response
def __call__(self, request: HttpRequest):
if not request.path.startswith('/api/'):
return self.get_response(request)
url = resolve(request.path_info)
if 'organizer' in url.kwargs:
request.organizer = Organizer.objects.filter(
slug=url.kwargs['organizer'],
).first()
with scope(organizer=getattr(request, 'organizer', None)):
return self.get_response(request)

View File

@@ -8,31 +8,33 @@ from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import ( from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer, AnswerCreateSerializer, AnswerSerializer, InlineSeatSerializer,
) )
from pretix.base.models import Quota from pretix.base.models import Quota, Seat
from pretix.base.models.orders import CartPosition from pretix.base.models.orders import CartPosition
class CartPositionSerializer(I18nAwareModelSerializer): class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True) answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
class Meta: class Meta:
model = CartPosition model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax', 'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers',) 'answers', 'seat')
class CartPositionCreateSerializer(I18nAwareModelSerializer): class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False) answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False) expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True) attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
model = CartPosition model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers',) 'subevent', 'expires', 'includes_tax', 'answers', 'seat')
def create(self, validated_data): def create(self, validated_data):
answers_data = validated_data.pop('answers') answers_data = validated_data.pop('answers')
@@ -71,6 +73,22 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
validated_data['attendee_name_parts'] = { validated_data['attendee_name_parts'] = {
'_legacy': attendee_name '_legacy': attendee_name
} }
seated = validated_data.get('item').seat_category_mappings.filter(subevent=validated_data.get('subevent')).exists()
if validated_data.get('seat'):
if not seated:
raise ValidationError('The specified product does not allow to choose a seat.')
try:
seat = self.context['event'].seats.get(seat_guid=validated_data['seat'], subevent=validated_data.get('subevent'))
except Seat.DoesNotExist:
raise ValidationError('The specified seat does not exist.')
else:
validated_data['seat'] = seat
if not seat.is_available():
raise ValidationError(ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name))
elif seated:
raise ValidationError('The specified product requires to choose a seat.')
cp = CartPosition.objects.create(event=self.context['event'], **validated_data) cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data: for answ_data in answers_data:

View File

@@ -11,6 +11,9 @@ from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event, TaxRule from pretix.base.models import Event, TaxRule
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import SubEventItem, SubEventItemVariation from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
class MetaDataField(Field): class MetaDataField(Field):
@@ -26,6 +29,22 @@ class MetaDataField(Field):
} }
class SeatCategoryMappingField(Field):
def to_representation(self, value):
qs = value.seat_category_mappings.all()
if isinstance(value, Event):
qs = qs.filter(subevent=None)
return {
v.layout_category: v.product_id for v in qs
}
def to_internal_value(self, data):
return {
'seat_category_mapping': data or {}
}
class PluginsField(Field): class PluginsField(Field):
def to_representation(self, obj): def to_representation(self, obj):
@@ -45,12 +64,14 @@ class PluginsField(Field):
class EventSerializer(I18nAwareModelSerializer): class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*') meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*') plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
class Meta: class Meta:
model = Event model = Event
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from', fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'date_to', 'date_admission', 'is_public', 'presale_start', 'date_to', 'date_admission', 'is_public', 'presale_start',
'presale_end', 'location', 'has_subevents', 'meta_data', 'plugins') 'presale_end', 'location', 'has_subevents', 'meta_data', 'seating_plan',
'plugins', 'seat_category_mapping')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -61,6 +82,9 @@ class EventSerializer(I18nAwareModelSerializer):
Event.clean_dates(data.get('date_from'), data.get('date_to')) Event.clean_dates(data.get('date_from'), data.get('date_to'))
Event.clean_presale(data.get('presale_start'), data.get('presale_end')) Event.clean_presale(data.get('presale_start'), data.get('presale_end'))
if full_data.get('has_subevents') and full_data.get('seating_plan'):
raise ValidationError('Event series should not directly be assigned a seating plan.')
return data return data
def validate_has_subevents(self, value): def validate_has_subevents(self, value):
@@ -92,6 +116,27 @@ class EventSerializer(I18nAwareModelSerializer):
raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key)) raise ValidationError(_('Meta data property \'{name}\' does not exist.').format(name=key))
return value return value
def validate_seating_plan(self, value):
if value and value.organizer != self.context['request'].organizer:
raise ValidationError('Invalid seating plan.')
if self.instance and self.instance.pk:
try:
validate_plan_change(self.instance, None, value)
except SeatProtected as e:
raise ValidationError(str(e))
return value
def validate_seat_category_mapping(self, value):
if value and value['seat_category_mapping'] and (not self.instance or not self.instance.pk):
raise ValidationError('You cannot specify seat category mappings on event creation.')
item_cache = {i.pk: i for i in self.instance.items.all()}
result = {}
for k, item in value['seat_category_mapping'].items():
if item not in item_cache:
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
result[k] = item_cache[item]
return {'seat_category_mapping': result}
def validate_plugins(self, value): def validate_plugins(self, value):
from pretix.base.plugins import get_all_plugins from pretix.base.plugins import get_all_plugins
@@ -109,6 +154,7 @@ class EventSerializer(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)
validated_data.pop('seat_category_mapping', None)
plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(',')) plugins = validated_data.pop('plugins', settings.PRETIX_PLUGINS_DEFAULT.split(','))
event = super().create(validated_data) event = super().create(validated_data)
@@ -120,6 +166,10 @@ class EventSerializer(I18nAwareModelSerializer):
value=value value=value
) )
# Seats
if event.seating_plan:
generate_seats(event, None, event.seating_plan, {})
# Plugins # Plugins
if plugins is not None: if plugins is not None:
event.set_active_plugins(plugins) event.set_active_plugins(plugins)
@@ -131,6 +181,7 @@ class EventSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data): def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None) plugins = validated_data.pop('plugins', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
event = super().update(instance, validated_data) event = super().update(instance, validated_data)
# Meta data # Meta data
@@ -151,6 +202,29 @@ class EventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data: if prop.name not in meta_data:
current_object.delete() current_object.delete()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = {
m.layout_category: m
for m in event.seat_category_mappings.filter(subevent=None)
}
if not event.seating_plan:
seat_category_mapping = {}
for key, value in seat_category_mapping.items():
if key in current_mappings:
m = current_mappings.pop(key)
m.product = value
m.save()
else:
event.seat_category_mappings.create(product=value, layout_category=key)
for m in current_mappings.values():
m.delete()
if 'seating_plan' in validated_data or seat_category_mapping is not None:
generate_seats(event, None, event.seating_plan, {
m.layout_category: m.product
for m in event.seat_category_mappings.select_related('product').filter(subevent=None)
})
# Plugins # Plugins
if plugins is not None: if plugins is not None:
event.set_active_plugins(plugins) event.set_active_plugins(plugins)
@@ -196,14 +270,15 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class SubEventSerializer(I18nAwareModelSerializer): class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False) item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True, required=False)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False) variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True, required=False)
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
event = SlugRelatedField(slug_field='slug', read_only=True) event = SlugRelatedField(slug_field='slug', read_only=True)
meta_data = MetaDataField(source='*') meta_data = MetaDataField(source='*')
class Meta: class Meta:
model = SubEvent model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission', fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'event', 'is_public', 'presale_start', 'presale_end', 'location', 'event', 'is_public', 'seating_plan',
'item_price_overrides', 'variation_price_overrides', 'meta_data') 'item_price_overrides', 'variation_price_overrides', 'meta_data', 'seat_category_mapping')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -225,6 +300,25 @@ class SubEventSerializer(I18nAwareModelSerializer):
def validate_variation_price_overrides(self, data): def validate_variation_price_overrides(self, data):
return list(filter(lambda i: 'variation' in i, data)) return list(filter(lambda i: 'variation' in i, data))
def validate_seating_plan(self, value):
if value and value.organizer != self.context['request'].organizer:
raise ValidationError('Invalid seating plan.')
if self.instance and self.instance.pk:
try:
validate_plan_change(self.context['request'].event, self.instance, value)
except SeatProtected as e:
raise ValidationError(str(e))
return value
def validate_seat_category_mapping(self, value):
item_cache = {i.pk: i for i in self.context['request'].event.items.all()}
result = {}
for k, item in value['seat_category_mapping'].items():
if item not in item_cache:
raise ValidationError('Item \'{id}\' does not exist.'.format(id=item))
result[k] = item_cache[item]
return {'seat_category_mapping': result}
@cached_property @cached_property
def meta_properties(self): def meta_properties(self):
return { return {
@@ -242,6 +336,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {} item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {} variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().create(validated_data) subevent = super().create(validated_data)
for item_price_override_data in item_price_overrides_data: for item_price_override_data in item_price_overrides_data:
@@ -257,6 +352,18 @@ class SubEventSerializer(I18nAwareModelSerializer):
value=value value=value
) )
# Seats
if subevent.seating_plan:
if seat_category_mapping is not None:
for key, value in seat_category_mapping.items():
self.context['request'].event.seat_category_mappings.create(
product=value, layout_category=key, subevent=subevent
)
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
m.layout_category: m.product
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
})
return subevent return subevent
@transaction.atomic @transaction.atomic
@@ -264,6 +371,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {} item_price_overrides_data = validated_data.pop('subeventitem_set') if 'subeventitem_set' in validated_data else {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {} variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None) meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().update(instance, validated_data) subevent = super().update(instance, validated_data)
existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)} existing_item_overrides = {item.item: item.id for item in SubEventItem.objects.filter(subevent=subevent)}
@@ -300,6 +408,31 @@ class SubEventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data: if prop.name not in meta_data:
current_object.delete() current_object.delete()
# Seats
if seat_category_mapping is not None or ('seating_plan' in validated_data and validated_data['seating_plan'] is None):
current_mappings = {
m.layout_category: m
for m in self.context['request'].event.seat_category_mappings.filter(subevent=subevent)
}
if not subevent.seating_plan:
seat_category_mapping = {}
for key, value in seat_category_mapping.items():
if key in current_mappings:
m = current_mappings.pop(key)
m.product = value
m.save()
else:
self.context['request'].event.seat_category_mappings.create(
product=value, layout_category=key, subevent=subevent
)
for m in current_mappings.values():
m.delete()
if 'seating_plan' in validated_data or seat_category_mapping is not None:
generate_seats(self.context['request'].event, subevent, subevent.seating_plan, {
m.layout_category: m.product
for m in self.context['request'].event.seat_category_mappings.select_related('product').filter(subevent=subevent)
})
return subevent return subevent

View File

@@ -118,7 +118,8 @@ class ItemSerializer(I18nAwareModelSerializer):
'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',
'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets') 'addons', 'bundles', 'original_price', 'require_approval', 'generate_tickets',
'show_quota_left', 'hidden_if_available', 'allow_waitinglist')
read_only_fields = ('has_variations', 'picture') read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self): def get_serializer_context(self):
@@ -200,15 +201,25 @@ class InlineQuestionOptionSerializer(I18nAwareModelSerializer):
fields = ('id', 'identifier', 'answer', 'position') fields = ('id', 'identifier', 'answer', 'position')
class LegacyDependencyValueField(serializers.CharField):
def to_representation(self, obj):
return obj[0] if obj else None
def to_internal_value(self, data):
return [data] if data else []
class QuestionSerializer(I18nAwareModelSerializer): class QuestionSerializer(I18nAwareModelSerializer):
options = InlineQuestionOptionSerializer(many=True, required=False) options = InlineQuestionOptionSerializer(many=True, required=False)
identifier = serializers.CharField(allow_null=True) identifier = serializers.CharField(allow_null=True)
dependency_value = LegacyDependencyValueField(source='dependency_values', required=False, allow_null=True)
class Meta: class Meta:
model = Question model = Question
fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position', fields = ('id', 'question', 'type', 'required', 'items', 'options', 'position',
'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_value', 'ask_during_checkin', 'identifier', 'dependency_question', 'dependency_values',
'hidden') 'hidden', 'dependency_value')
def validate_identifier(self, value): def validate_identifier(self, value):
Question._clean_identifier(self.context['event'], value, self.instance) Question._clean_identifier(self.context['event'], value, self.instance)
@@ -262,6 +273,7 @@ class QuestionSerializer(I18nAwareModelSerializer):
def create(self, validated_data): def create(self, validated_data):
options_data = validated_data.pop('options') if 'options' in validated_data else [] options_data = validated_data.pop('options') if 'options' in validated_data else []
items = validated_data.pop('items') items = validated_data.pop('items')
question = Question.objects.create(**validated_data) question = Question.objects.create(**validated_data)
question.items.set(items) question.items.set(items)
for opt_data in options_data: for opt_data in options_data:
@@ -273,7 +285,7 @@ class QuotaSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Quota model = Quota
fields = ('id', 'name', 'size', 'items', 'variations', 'subevent') fields = ('id', 'name', 'size', 'items', 'variations', 'subevent', 'closed', 'close_when_sold_out')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)

View File

@@ -1,5 +1,4 @@
import json import json
from collections import Counter
from decimal import Decimal from decimal import Decimal
from django.utils.timezone import now from django.utils.timezone import now
@@ -15,7 +14,7 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, SubEvent, OrderPosition, Question, QuestionAnswer, Seat, SubEvent,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -71,6 +70,13 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()] return [o.identifier for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta:
model = Seat
fields = ('id', 'name', 'seat_guid')
class AnswerSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -166,12 +172,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
downloads = PositionDownloadsField(source='*') downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*') pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data') 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -305,7 +312,6 @@ class OrderSerializer(I18nAwareModelSerializer):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer # Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update. # (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = ['comment', 'checkin_attention', 'email', 'locale'] update_fields = ['comment', 'checkin_attention', 'email', 'locale']
print(validated_data)
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -430,11 +436,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
addon_to = serializers.IntegerField(required=False, allow_null=True) addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False) secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True) attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers') 'secret', 'addon_to', 'subevent', 'answers', 'seat')
def validate_secret(self, secret): def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -590,6 +597,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]} {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
for p in data for p in data
] ]
else:
for i, p in enumerate(data):
p['positionid'] = i + 1
if any(errs): if any(errs):
raise ValidationError(errs) raise ValidationError(errs)
@@ -615,13 +625,15 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
ia = None ia = None
with self.context['event'].lock() as now_dt: with self.context['event'].lock() as now_dt:
quotadiff = Counter() free_seats = set()
seats_seen = set()
consume_carts = validated_data.pop('consume_carts', []) consume_carts = validated_data.pop('consume_carts', [])
delete_cps = [] delete_cps = []
quota_avail_cache = {} quota_avail_cache = {}
if consume_carts: if consume_carts:
for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts): for cp in CartPosition.objects.filter(
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now()
):
quotas = (cp.variation.quotas.filter(subevent=cp.subevent) quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent)) if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
for quota in quotas: for quota in quotas:
@@ -630,7 +642,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if quota_avail_cache[quota][1] is not None: if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1 quota_avail_cache[quota][1] += 1
if cp.expires > now_dt: if cp.expires > now_dt:
quotadiff.subtract(quotas) if cp.seat:
free_seats.add(cp.seat)
delete_cps.append(cp) delete_cps.append(cp)
errs = [{} for p in positions_data] errs = [{} for p in positions_data]
@@ -658,7 +671,22 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
) )
] ]
quotadiff.update(new_quotas) for i, pos_data in enumerate(positions_data):
seated = pos_data.get('item').seat_category_mappings.filter(subevent=pos_data.get('subevent')).exists()
if pos_data.get('seat'):
if not seated:
errs[i]['seat'] = ['The specified product does not allow to choose a seat.']
try:
seat = self.context['event'].seats.get(seat_guid=pos_data['seat'], subevent=pos_data.get('subevent'))
except Seat.DoesNotExist:
errs[i]['seat'] = ['The specified seat does not exist.']
else:
pos_data['seat'] = seat
if (seat not in free_seats and not seat.is_available()) or seat in seats_seen:
errs[i]['seat'] = [ugettext_lazy('The selected seat "{seat}" is not available.').format(seat=seat.name)]
seats_seen.add(seat)
elif seated:
errs[i]['seat'] = ['The specified product requires to choose a seat.']
if any(errs): if any(errs):
raise ValidationError({'positions': errs}) raise ValidationError({'positions': errs})

View File

@@ -1,8 +1,20 @@
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Organizer from pretix.api.serializers.order import CompatibleJSONField
from pretix.base.models import Organizer, SeatingPlan
from pretix.base.models.seating import SeatingPlanLayoutValidator
class OrganizerSerializer(I18nAwareModelSerializer): class OrganizerSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Organizer model = Organizer
fields = ('name', 'slug') fields = ('name', 'slug')
class SeatingPlanSerializer(I18nAwareModelSerializer):
layout = CompatibleJSONField(
validators=[SeatingPlanLayoutValidator()]
)
class Meta:
model = SeatingPlan
fields = ('id', 'name', 'layout')

View File

@@ -27,7 +27,7 @@ class VoucherSerializer(I18nAwareModelSerializer):
model = Voucher model = Voucher
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota', fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota', 'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent') 'tag', 'comment', 'subevent', 'show_hidden_items')
read_only_fields = ('id', 'redeemed') read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer list_serializer_class = VoucherListSerializer

View File

@@ -2,6 +2,7 @@ from datetime import timedelta
from django.dispatch import Signal, receiver from django.dispatch import Signal, receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.api.models import ApiCall, WebHookCall from pretix.api.models import ApiCall, WebHookCall
from pretix.base.signals import periodic_task from pretix.base.signals import periodic_task
@@ -17,10 +18,12 @@ instances.
@receiver(periodic_task) @receiver(periodic_task)
@scopes_disabled()
def cleanup_webhook_logs(sender, **kwargs): def cleanup_webhook_logs(sender, **kwargs):
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete() WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
@receiver(periodic_task) @receiver(periodic_task)
@scopes_disabled()
def cleanup_api_logs(sender, **kwargs): def cleanup_api_logs(sender, **kwargs):
ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete() ApiCall.objects.filter(created__lte=now() - timedelta(hours=24)).delete()

View File

@@ -18,6 +18,7 @@ orga_router = routers.DefaultRouter()
orga_router.register(r'events', event.EventViewSet) orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet) orga_router.register(r'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet) orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
event_router = routers.DefaultRouter() event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet) event_router.register(r'subevents', event.SubEventViewSet)

View File

@@ -24,7 +24,7 @@ class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnly
return CartPosition.objects.filter( return CartPosition.objects.filter(
event=self.request.event, event=self.request.event,
cart_id__endswith="@api" cart_id__endswith="@api"
) ).select_related('seat').prefetch_related('answers')
def get_serializer_context(self): def get_serializer_context(self):
ctx = super().get_serializer_context() ctx = super().get_serializer_context()

View File

@@ -6,6 +6,7 @@ from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.fields import DateTimeField from rest_framework.fields import DateTimeField
@@ -24,11 +25,11 @@ from pretix.base.services.checkin import (
) )
from pretix.helpers.database import FixedOrderBy from pretix.helpers.database import FixedOrderBy
with scopes_disabled():
class CheckinListFilter(FilterSet): class CheckinListFilter(FilterSet):
class Meta: class Meta:
model = CheckinList model = CheckinList
fields = ['subevent'] fields = ['subevent']
class CheckinListViewSet(viewsets.ModelViewSet): class CheckinListViewSet(viewsets.ModelViewSet):
@@ -92,6 +93,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
) )
if not clist.all_products: if not clist.all_products:
pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True)) pqs = pqs.filter(item__in=clist.limit_products.values_list('id', flat=True))
cqs = cqs.filter(position__item__in=clist.limit_products.values_list('id', flat=True))
ev = clist.subevent or clist.event ev = clist.subevent or clist.event
response = { response = {
@@ -146,15 +148,16 @@ class CheckinListViewSet(viewsets.ModelViewSet):
return Response(response) return Response(response)
class CheckinOrderPositionFilter(OrderPositionFilter): with scopes_disabled():
class CheckinOrderPositionFilter(OrderPositionFilter):
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value) return queryset.filter(last_checked_in__isnull=not value)
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet): class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.objects.none() queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter) filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid') ordering = ('attendee_name_cached', 'positionid')
ordering_fields = ( ordering_fields = (
@@ -229,7 +232,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
) )
)) ))
).select_related( ).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address' 'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
@@ -239,7 +242,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
), ),
'answers', 'answers__options', 'answers__question', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order') ).select_related('item', 'variation', 'order', 'addon_to', 'order__invoice_address', 'order', 'seat')
if not self.checkinlist.all_products: if not self.checkinlist.all_products:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True)) qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
@@ -278,6 +281,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
nonce=nonce, nonce=nonce,
datetime=dt, datetime=dt,
questions_supported=self.request.data.get('questions_supported', True), questions_supported=self.request.data.get('questions_supported', True),
canceled_supported=self.request.data.get('canceled_supported', False),
user=self.request.user, user=self.request.user,
auth=self.request.auth, auth=self.request.auth,
) )

View File

@@ -3,6 +3,7 @@ from django.db import transaction
from django.db.models import ProtectedError, Q from django.db.models import ProtectedError, Q
from django.utils.timezone import now from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, viewsets from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -18,51 +19,51 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class EventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class EventFilter(FilterSet): class Meta:
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs') model = Event
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs') fields = ['is_public', 'live', 'has_subevents']
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta: def ends_after_qs(self, queryset, name, value):
model = Event expr = (
fields = ['is_public', 'live', 'has_subevents'] Q(has_subevents=False) &
Q(
def ends_after_qs(self, queryset, name, value): Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
expr = ( | Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
Q(has_subevents=False) & )
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
) )
)
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
)
if value:
return queryset.filter(expr) return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value): def is_past_qs(self, queryset, name, value):
expr = ( expr = (
Q(has_subevents=False) & Q(has_subevents=False) &
Q( Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now())) Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now())) | Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
) )
) if value:
if value: return queryset.filter(expr)
return queryset.filter(expr) else:
else: return queryset.exclude(expr)
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class EventViewSet(viewsets.ModelViewSet): class EventViewSet(viewsets.ModelViewSet):
@@ -85,7 +86,7 @@ class EventViewSet(viewsets.ModelViewSet):
) )
return qs.prefetch_related( return qs.prefetch_related(
'meta_values', 'meta_values__property' 'meta_values', 'meta_values__property', 'seat_category_mappings'
) )
def perform_update(self, serializer): def perform_update(self, serializer):
@@ -182,41 +183,42 @@ class CloneEventViewSet(viewsets.ModelViewSet):
) )
class SubEventFilter(FilterSet): with scopes_disabled():
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs') class SubEventFilter(FilterSet):
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs') is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs') is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta: class Meta:
model = SubEvent model = SubEvent
fields = ['active', 'event__live'] fields = ['active', 'event__live']
def ends_after_qs(self, queryset, name, value): def ends_after_qs(self, queryset, name, value):
expr = Q( expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value)) Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value)) | Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
) )
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
if value:
return queryset.filter(expr) return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value): def is_past_qs(self, queryset, name, value):
expr = Q( expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now())) Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now())) | Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
) )
if value: if value:
return queryset.filter(expr) return queryset.filter(expr)
else: else:
return queryset.exclude(expr) return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet): class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -240,12 +242,18 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
event__in=self.request.user.get_events_with_any_permission() event__in=self.request.user.get_events_with_any_permission()
) )
return qs.prefetch_related( return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set' 'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
) )
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
super().perform_update(serializer) super().perform_update(serializer)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
serializer.instance.log_action( serializer.instance.log_action(
'pretix.subevent.changed', 'pretix.subevent.changed',
user=self.request.user, user=self.request.user,

View File

@@ -3,6 +3,7 @@ from django.db.models import Q
from django.shortcuts import get_object_or_404 from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -21,19 +22,19 @@ from pretix.base.models import (
) )
from pretix.helpers.dicts import merge_dicts from pretix.helpers.dicts import merge_dicts
with scopes_disabled():
class ItemFilter(FilterSet):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
class ItemFilter(FilterSet): def tax_rate_qs(self, queryset, name, value):
tax_rate = django_filters.CharFilter(method='tax_rate_qs') if value in ("0", "None", "0.00"):
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0))
else:
return queryset.filter(tax_rule__rate=value)
def tax_rate_qs(self, queryset, name, value): class Meta:
if value in ("0", "None", "0.00"): model = Item
return queryset.filter(Q(tax_rule__isnull=True) | Q(tax_rule__rate=0)) fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
else:
return queryset.filter(tax_rule__rate=value)
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class ItemViewSet(ConditionalListView, viewsets.ModelViewSet): class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -65,7 +66,14 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx return ctx
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.item.changed', 'pretix.event.item.changed',
user=self.request.user, user=self.request.user,
@@ -312,10 +320,11 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
class QuestionFilter(FilterSet): with scopes_disabled():
class Meta: class QuestionFilter(FilterSet):
model = Question class Meta:
fields = ['ask_during_checkin', 'required', 'identifier'] model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet): class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -411,10 +420,11 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance) super().perform_destroy(instance)
class QuotaFilter(FilterSet): with scopes_disabled():
class Meta: class QuotaFilter(FilterSet):
model = Quota class Meta:
fields = ['subevent'] model = Quota
fields = ['subevent']
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet): class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -452,9 +462,30 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx return ctx
def perform_update(self, serializer): def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
current_subevent = serializer.instance.subevent current_subevent = serializer.instance.subevent
serializer.save(event=self.request.event) serializer.save(event=self.request.event)
request_subevent = serializer.instance.subevent request_subevent = serializer.instance.subevent
if serializer.data == original_data:
# Performance optimization: If nothing was changed, we do not need to save or log anything.
# This costs us a few cycles on save, but avoids thousands of lines in our log.
return
if original_data['closed'] is True and serializer.instance.closed is False:
serializer.instance.log_action(
'pretix.event.quota.opened',
user=self.request.user,
auth=self.request.auth,
)
elif original_data['closed'] is False and serializer.instance.closed is True:
serializer.instance.log_action(
'pretix.event.quota.closed',
user=self.request.user,
auth=self.request.auth,
)
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.quota.changed', 'pretix.event.quota.changed',
user=self.request.user, user=self.request.user,

View File

@@ -11,6 +11,7 @@ from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import mixins, serializers, status, viewsets from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
@@ -50,17 +51,17 @@ from pretix.base.signals import (
) )
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
with scopes_disabled():
class OrderFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class OrderFilter(FilterSet): class Meta:
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact') model = Order
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact') fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class OrderViewSet(viewsets.ModelViewSet): class OrderViewSet(viewsets.ModelViewSet):
@@ -92,8 +93,8 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to', 'item__category', 'addon_to', 'seat',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
) )
) )
) )
@@ -102,7 +103,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch( Prefetch(
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
) )
) )
) )
@@ -531,48 +532,49 @@ class OrderViewSet(viewsets.ModelViewSet):
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
class OrderPositionFilter(FilterSet): with scopes_disabled():
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') class OrderPositionFilter(FilterSet):
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs') order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
attendee_name = django_filters.CharFilter(method='attendee_name_qs') has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
search = django_filters.CharFilter(method='search_qs') attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value): def search_qs(self, queryset, name, value):
return queryset.filter( return queryset.filter(
Q(secret__istartswith=value) Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value) | Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value) | Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value) | Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value) | Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value) | Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value) | Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value) | Q(order__email__icontains=value)
) )
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value) return queryset.filter(checkins__isnull=not value)
def attendee_name_qs(self, queryset, name, value): def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value)) return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = { fields = {
'item': ['exact', 'in'], 'item': ['exact', 'in'],
'variation': ['exact', 'in'], 'variation': ['exact', 'in'],
'secret': ['exact'], 'secret': ['exact'],
'order__status': ['exact', 'in'], 'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'], 'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in'], 'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'], 'pseudonymization_id': ['exact'],
'voucher__code': ['exact'], 'voucher__code': ['exact'],
'voucher': ['exact'], 'voucher': ['exact'],
} }
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none() queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter) filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid') ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
@@ -609,13 +611,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
) )
)) ))
).select_related( ).select_related(
'item', 'variation', 'item__category', 'addon_to' 'item', 'variation', 'item__category', 'addon_to', 'seat'
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question' 'checkins', 'answers', 'answers__options', 'answers__question'
).select_related( ).select_related(
'item', 'order', 'order__event', 'order__event__organizer' 'item', 'order', 'order__event', 'order__event__organizer', 'seat'
) )
return qs return qs
@@ -960,22 +962,23 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer.save() serializer.save()
class InvoiceFilter(FilterSet): with scopes_disabled():
refers = django_filters.CharFilter(method='refers_qs') class InvoiceFilter(FilterSet):
number = django_filters.CharFilter(method='nr_qs') refers = django_filters.CharFilter(method='refers_qs')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') number = django_filters.CharFilter(method='nr_qs')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
def refers_qs(self, queryset, name, value): def refers_qs(self, queryset, name, value):
return queryset.annotate( return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no') refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value) ).filter(refers_nr__iexact=value)
def nr_qs(self, queryset, name, value): def nr_qs(self, queryset, name, value):
return queryset.filter(nr__iexact=value) return queryset.filter(nr__iexact=value)
class Meta: class Meta:
model = Invoice model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale'] fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
class RetryException(APIException): class RetryException(APIException):

View File

@@ -1,8 +1,12 @@
from rest_framework import filters, viewsets from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
from pretix.api.models import OAuthAccessToken from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.organizer import OrganizerSerializer from pretix.api.serializers.organizer import (
from pretix.base.models import Organizer OrganizerSerializer, SeatingPlanSerializer,
)
from pretix.base.models import Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet): class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -30,3 +34,50 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
return Organizer.objects.filter(pk=self.request.auth.organizer_id) return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else: else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id) return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)
class SeatingPlanViewSet(viewsets.ModelViewSet):
serializer_class = SeatingPlanSerializer
queryset = SeatingPlan.objects.none()
permission = 'can_change_organizer_settings'
write_permission = 'can_change_organizer_settings'
def get_queryset(self):
return self.request.organizer.seating_plans.all()
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['organizer'] = self.request.organizer
return ctx
def perform_create(self, serializer):
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.seatingplan.added',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': inst.pk})
)
def perform_update(self, serializer):
if serializer.instance.events.exists() or serializer.instance.subevents.exists():
raise PermissionDenied('This plan can not be changed while it is in use for an event.')
inst = serializer.save(organizer=self.request.organizer)
self.request.organizer.log_action(
'pretix.seatingplan.changed',
user=self.request.user,
auth=self.request.auth,
data=merge_dicts(self.request.data, {'id': serializer.instance.pk})
)
return inst
def perform_destroy(self, instance):
if instance.events.exists() or instance.subevents.exists():
raise PermissionDenied('This plan can not be deleted while it is in use for an event.')
instance.log_action(
'pretix.seatingplan.deleted',
user=self.request.user,
auth=self.request.auth,
data={'id': instance.pk}
)
instance.delete()

View File

@@ -6,6 +6,7 @@ from django.utils.timezone import now
from django_filters.rest_framework import ( from django_filters.rest_framework import (
BooleanFilter, DjangoFilterBackend, FilterSet, BooleanFilter, DjangoFilterBackend, FilterSet,
) )
from django_scopes import scopes_disabled
from rest_framework import status, viewsets from rest_framework import status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied from rest_framework.exceptions import PermissionDenied
@@ -15,22 +16,22 @@ from rest_framework.response import Response
from pretix.api.serializers.voucher import VoucherSerializer from pretix.api.serializers.voucher import VoucherSerializer
from pretix.base.models import Voucher from pretix.base.models import Voucher
with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
class VoucherFilter(FilterSet): class Meta:
active = BooleanFilter(method='filter_active') model = Voucher
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
class Meta: def filter_active(self, queryset, name, value):
model = Voucher if value:
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota', return queryset.filter(Q(redeemed__lt=F('max_usages')) &
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent'] (Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
else:
def filter_active(self, queryset, name, value): return queryset.filter(Q(redeemed__gte=F('max_usages')) |
if value: (Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
return queryset.filter(Q(redeemed__lt=F('max_usages')) &
(Q(valid_until__isnull=True) | Q(valid_until__gt=now())))
else:
return queryset.filter(Q(redeemed__gte=F('max_usages')) |
(Q(valid_until__isnull=False) & Q(valid_until__lte=now())))
class VoucherViewSet(viewsets.ModelViewSet): class VoucherViewSet(viewsets.ModelViewSet):

View File

@@ -1,5 +1,6 @@
import django_filters import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets from rest_framework import viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied, ValidationError from rest_framework.exceptions import PermissionDenied, ValidationError
@@ -10,16 +11,16 @@ from pretix.api.serializers.waitinglist import WaitingListSerializer
from pretix.base.models import WaitingListEntry from pretix.base.models import WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException from pretix.base.models.waitinglist import WaitingListException
with scopes_disabled():
class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
class WaitingListFilter(FilterSet): def has_voucher_qs(self, queryset, name, value):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs') return queryset.filter(voucher__isnull=not value)
def has_voucher_qs(self, queryset, name, value): class Meta:
return queryset.filter(voucher__isnull=not value) model = WaitingListEntry
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
class Meta:
model = WaitingListEntry
fields = ['item', 'variation', 'email', 'locale', 'has_voucher', 'subevent']
class WaitingListViewSet(viewsets.ModelViewSet): class WaitingListViewSet(viewsets.ModelViewSet):

View File

@@ -8,6 +8,7 @@ from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import scope, scopes_disabled
from requests import RequestException from requests import RequestException
from pretix.api.models import WebHook, WebHookCall, WebHookEventListener from pretix.api.models import WebHook, WebHookCall, WebHookEventListener
@@ -203,51 +204,52 @@ def notify_webhooks(logentry_id: int):
@app.task(base=ProfiledTask, bind=True, max_retries=9) @app.task(base=ProfiledTask, bind=True, max_retries=9)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int): def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours # 9 retries with 2**(2*x) timing is roughly 72 hours
logentry = LogEntry.all.get(id=logentry_id) with scopes_disabled():
webhook = WebHook.objects.get(id=webhook_id) webhook = WebHook.objects.get(id=webhook_id)
with scope(organizer=webhook.organizer):
logentry = LogEntry.all.get(id=logentry_id)
types = get_all_webhook_events()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
types = get_all_webhook_events() payload = event_type.build_payload(logentry)
event_type = types.get(action_type) t = time.time()
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
t = time.time()
try:
try: try:
resp = requests.post( try:
webhook.target_url, resp = requests.post(
json=payload, webhook.target_url,
allow_redirects=False json=payload,
) allow_redirects=False
WebHookCall.objects.create( )
webhook=webhook, WebHookCall.objects.create(
action_type=logentry.action_type, webhook=webhook,
target_url=webhook.target_url, action_type=logentry.action_type,
is_retry=self.request.retries > 0, target_url=webhook.target_url,
execution_time=time.time() - t, is_retry=self.request.retries > 0,
return_code=resp.status_code, execution_time=time.time() - t,
payload=json.dumps(payload), return_code=resp.status_code,
response_body=resp.text[:1024 * 1024], payload=json.dumps(payload),
success=200 <= resp.status_code <= 299 response_body=resp.text[:1024 * 1024],
) success=200 <= resp.status_code <= 299
if resp.status_code == 410: )
webhook.enabled = False if resp.status_code == 410:
webhook.save() webhook.enabled = False
elif resp.status_code > 299: webhook.save()
elif resp.status_code > 299:
raise self.retry(countdown=2 ** (self.request.retries * 2))
except RequestException as e:
WebHookCall.objects.create(
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2)) raise self.retry(countdown=2 ** (self.request.retries * 2))
except RequestException as e: except MaxRetriesExceededError:
WebHookCall.objects.create( pass
webhook=webhook,
action_type=logentry.action_type,
target_url=webhook.target_url,
is_retry=self.request.retries > 0,
execution_time=time.time() - t,
return_code=0,
payload=json.dumps(payload),
response_body=str(e)[:1024 * 1024]
)
raise self.retry(countdown=2 ** (self.request.retries * 2))
except MaxRetriesExceededError:
pass

View File

@@ -71,6 +71,8 @@ class BaseExporter:
:type form_data: dict :type form_data: dict
:param form_data: The form data of the export details form :param form_data: The form data of the export details form
:param output_file: You can optionally accept a parameter that will be given a file handle to write the
output to. In this case, you can return None instead of the file content.
Note: If you use a ``ModelChoiceField`` (or a ``ModelMultipleChoiceField``), the Note: If you use a ``ModelChoiceField`` (or a ``ModelMultipleChoiceField``), the
``form_data`` will not contain the model instance but only it's primary key (or ``form_data`` will not contain the model instance but only it's primary key (or
@@ -111,14 +113,20 @@ class ListExporter(BaseExporter):
def get_filename(self): def get_filename(self):
return 'export.csv' return 'export.csv'
def _render_csv(self, form_data, **kwargs): def _render_csv(self, form_data, output_file=None, **kwargs):
output = io.StringIO() if output_file:
writer = csv.writer(output, **kwargs) writer = csv.writer(output_file, **kwargs)
for line in self.iterate_list(form_data): for line in self.iterate_list(form_data):
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8") return self.get_filename() + '.csv', 'text/csv', None
else:
output = io.StringIO()
writer = csv.writer(output, **kwargs)
for line in self.iterate_list(form_data):
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data): def _render_xlsx(self, form_data, output_file=None):
wb = Workbook() wb = Workbook()
ws = wb.get_active_sheet() ws = wb.get_active_sheet()
try: try:
@@ -129,20 +137,24 @@ class ListExporter(BaseExporter):
for j, val in enumerate(line): for j, val in enumerate(line):
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f: if output_file:
wb.save(f.name) wb.save(output_file)
f.seek(0) return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', None
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read() else:
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
wb.save(f.name)
f.seek(0)
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
def render(self, form_data: dict) -> Tuple[str, str, bytes]: def render(self, form_data: dict, output_file=None) -> Tuple[str, str, bytes]:
if form_data.get('_format') == 'xlsx': if form_data.get('_format') == 'xlsx':
return self._render_xlsx(form_data) return self._render_xlsx(form_data, output_file=output_file)
elif form_data.get('_format') == 'default': elif form_data.get('_format') == 'default':
return self._render_csv(form_data, quoting=csv.QUOTE_NONNUMERIC, delimiter=',') return self._render_csv(form_data, quoting=csv.QUOTE_NONNUMERIC, delimiter=',', output_file=output_file)
elif form_data.get('_format') == 'csv-excel': elif form_data.get('_format') == 'csv-excel':
return self._render_csv(form_data, dialect='excel') return self._render_csv(form_data, dialect='excel', output_file=output_file)
elif form_data.get('_format') == 'semicolon': elif form_data.get('_format') == 'semicolon':
return self._render_csv(form_data, dialect='excel', delimiter=';') return self._render_csv(form_data, dialect='excel', delimiter=';', output_file=output_file)
class MultiSheetListExporter(ListExporter): class MultiSheetListExporter(ListExporter):
@@ -180,14 +192,20 @@ class MultiSheetListExporter(ListExporter):
def iterate_sheet(self, form_data, sheet): def iterate_sheet(self, form_data, sheet):
raise NotImplementedError() # noqa raise NotImplementedError() # noqa
def _render_sheet_csv(self, form_data, sheet, **kwargs): def _render_sheet_csv(self, form_data, sheet, output_file=None, **kwargs):
output = io.StringIO() if output_file:
writer = csv.writer(output, **kwargs) writer = csv.writer(output_file, **kwargs)
for line in self.iterate_sheet(form_data, sheet): for line in self.iterate_sheet(form_data, sheet):
writer.writerow(line) writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8") return self.get_filename() + '.csv', 'text/csv', None
else:
output = io.StringIO()
writer = csv.writer(output, **kwargs)
for line in self.iterate_sheet(form_data, sheet):
writer.writerow(line)
return self.get_filename() + '.csv', 'text/csv', output.getvalue().encode("utf-8")
def _render_xlsx(self, form_data): def _render_xlsx(self, form_data, output_file=None):
wb = Workbook() wb = Workbook()
ws = wb.get_active_sheet() ws = wb.get_active_sheet()
wb.remove(ws) wb.remove(ws)
@@ -197,19 +215,24 @@ class MultiSheetListExporter(ListExporter):
for j, val in enumerate(line): for j, val in enumerate(line):
ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val ws.cell(row=i + 1, column=j + 1).value = str(val) if not isinstance(val, KNOWN_TYPES) else val
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f: if output_file:
wb.save(f.name) wb.save(output_file)
f.seek(0) return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', None
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read() else:
with tempfile.NamedTemporaryFile(suffix='.xlsx') as f:
wb.save(f.name)
f.seek(0)
return self.get_filename() + '.xlsx', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', f.read()
def render(self, form_data: dict) -> Tuple[str, str, bytes]: def render(self, form_data: dict, output_file=None) -> Tuple[str, str, bytes]:
if form_data.get('_format') == 'xlsx': if form_data.get('_format') == 'xlsx':
return self._render_xlsx(form_data) return self._render_xlsx(form_data, output_file=output_file)
elif ':' in form_data.get('_format'): elif ':' in form_data.get('_format'):
sheet, f = form_data.get('_format').split(':') sheet, f = form_data.get('_format').split(':')
if f == 'default': if f == 'default':
return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',') return self._render_sheet_csv(form_data, sheet, quoting=csv.QUOTE_NONNUMERIC, delimiter=',',
output_file=output_file)
elif f == 'excel': elif f == 'excel':
return self._render_sheet_csv(form_data, sheet, dialect='excel') return self._render_sheet_csv(form_data, sheet, dialect='excel', output_file=output_file)
elif f == 'semicolon': elif f == 'semicolon':
return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';') return self._render_sheet_csv(form_data, sheet, dialect='excel', delimiter=';', output_file=output_file)

View File

@@ -20,7 +20,7 @@ class InvoiceExporter(BaseExporter):
identifier = 'invoices' identifier = 'invoices'
verbose_name = _('All invoices') verbose_name = _('All invoices')
def render(self, form_data: dict): def render(self, form_data: dict, output_file=None):
qs = self.event.invoices.filter(shredded=False) qs = self.event.invoices.filter(shredded=False)
if form_data.get('payment_provider'): if form_data.get('payment_provider'):
@@ -47,7 +47,7 @@ class InvoiceExporter(BaseExporter):
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
any = False any = False
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf: with ZipFile(output_file or os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs: for i in qs:
try: try:
if not i.file: if not i.file:
@@ -68,8 +68,11 @@ class InvoiceExporter(BaseExporter):
if not any: if not any:
return None return None
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf: if output_file:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read() return '{}_invoices.zip'.format(self.event.slug), 'application/zip', None
else:
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
@property @property
def export_form_fields(self): def export_form_fields(self):

View File

@@ -14,7 +14,7 @@ class LoginForm(forms.Form):
Base class for authenticating users. Extend this to get a form that accepts Base class for authenticating users. Extend this to get a form that accepts
username/password logins. username/password logins.
""" """
email = forms.EmailField(label=_("E-mail"), max_length=254) email = forms.EmailField(label=_("E-mail"), max_length=254, widget=forms.EmailInput(attrs={'autofocus': 'autofocus'}))
password = forms.CharField(label=_("Password"), widget=forms.PasswordInput) password = forms.CharField(label=_("Password"), widget=forms.PasswordInput)
keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False) keep_logged_in = forms.BooleanField(label=_("Keep me logged in"), required=False)

View File

@@ -1,6 +1,8 @@
import copy import copy
import json
import logging import logging
from decimal import Decimal from decimal import Decimal
from urllib.error import HTTPError
import dateutil.parser import dateutil.parser
import pytz import pytz
@@ -9,6 +11,8 @@ import vat_moss.id
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.forms import Select
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import get_language, ugettext_lazy as _ from django.utils.translation import get_language, ugettext_lazy as _
@@ -21,9 +25,10 @@ from pretix.base.forms.widgets import (
) )
from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES from pretix.base.models.tax import EU_COUNTRIES
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES, PERSON_NAME_TITLE_GROUPS
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import SplitDateTimeField from pretix.control.forms import SplitDateTimeField
from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
@@ -33,14 +38,18 @@ logger = logging.getLogger(__name__)
class NamePartsWidget(forms.MultiWidget): class NamePartsWidget(forms.MultiWidget):
widget = forms.TextInput widget = forms.TextInput
def __init__(self, scheme: dict, field: forms.Field, attrs=None): def __init__(self, scheme: dict, field: forms.Field, attrs=None, titles: list=None):
widgets = [] widgets = []
self.scheme = scheme self.scheme = scheme
self.field = field self.field = field
self.titles = titles
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
a = copy.copy(attrs) or {} a = copy.copy(attrs) or {}
a['data-fname'] = fname a['data-fname'] = fname
widgets.append(self.widget(attrs=a)) if fname == 'title' and self.titles:
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
else:
widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs) super().__init__(widgets, attrs)
def decompress(self, value): def decompress(self, value):
@@ -99,19 +108,34 @@ class NamePartsFormField(forms.MultiValueField):
'max_length': kwargs.pop('max_length', None), 'max_length': kwargs.pop('max_length', None),
} }
self.scheme_name = kwargs.pop('scheme') self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name) self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
if self.titles:
self.scheme_titles = PERSON_NAME_TITLE_GROUPS.get(self.titles)
else:
self.scheme_titles = None
self.one_required = kwargs.get('required', True) self.one_required = kwargs.get('required', True)
require_all_fields = kwargs.pop('require_all_fields', False) require_all_fields = kwargs.pop('require_all_fields', False)
kwargs['required'] = False kwargs['required'] = False
kwargs['widget'] = (kwargs.get('widget') or self.widget)( kwargs['widget'] = (kwargs.get('widget') or self.widget)(
scheme=self.scheme, field=self, **kwargs.pop('widget_kwargs', {}) scheme=self.scheme, titles=self.scheme_titles, field=self, **kwargs.pop('widget_kwargs', {})
) )
defaults.update(**kwargs) defaults.update(**kwargs)
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
defaults['label'] = label defaults['label'] = label
field = forms.CharField(**defaults) if fname == 'title' and self.scheme_titles:
field.part_name = fname d = dict(defaults)
fields.append(field) d.pop('max_length', None)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
)
field.part_name = fname
fields.append(field)
else:
field = forms.CharField(**defaults)
field.part_name = fname
fields.append(field)
super().__init__( super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs fields=fields, require_all_fields=False, *args, **kwargs
) )
@@ -156,6 +180,7 @@ class BaseQuestionsForm(forms.Form):
max_length=255, max_length=255,
required=event.settings.attendee_names_required, required=event.settings.attendee_names_required,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
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),
) )
@@ -277,7 +302,7 @@ class BaseQuestionsForm(forms.Form):
if q.dependency_question_id: if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-value'] = q.dependency_value field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
if q.type != 'M': if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional field._required = q.required and not self.all_optional
@@ -298,26 +323,24 @@ class BaseQuestionsForm(forms.Form):
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)} question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
def question_is_visible(parentid, qval): def question_is_visible(parentid, qvals):
parentq = question_cache[parentid] parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value): if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False return False
if 'question_%d' % parentid not in d: if 'question_%d' % parentid not in d:
return False return False
dval = d.get('question_%d' % parentid) dval = d.get('question_%d' % parentid)
if qval == 'True': return (
return dval ('True' in qvals and dval)
elif qval == 'False': or ('False' in qvals and not dval)
return not dval or (isinstance(dval, QuestionOption) and dval.identifier in qvals)
elif isinstance(dval, QuestionOption): or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals))
return dval.identifier == qval )
else:
return qval in [o.identifier for o in dval]
def question_is_required(q): def question_is_required(q):
return ( return (
q.required and q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value)) (not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
) )
if not self.all_optional: if not self.all_optional:
@@ -398,6 +421,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
max_length=255, max_length=255,
required=event.settings.invoice_name_required and not self.all_optional, required=event.settings.invoice_name_required and not self.all_optional,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Name'), label=_('Name'),
initial=(self.instance.name_parts if self.instance else self.instance.name_parts), initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
) )
@@ -451,7 +475,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'your country is currently not available. We will therefore ' 'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount ' 'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.')) 'back via the VAT reimbursement process.'))
except vat_moss.errors.WebServiceError: except (vat_moss.errors.WebServiceError, HTTPError):
logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
if self.request and self.vat_warning: if self.request and self.vat_warning:

View File

@@ -0,0 +1,58 @@
import json
import sys
from django.core.management.base import BaseCommand
from django.utils.timezone import override
from django_scopes import scope
from pretix.base.i18n import language
from pretix.base.models import Event, Organizer
from pretix.base.signals import register_data_exporters
class Command(BaseCommand):
help = "Run an exporter to get data out of pretix"
def add_arguments(self, parser):
parser.add_argument('organizer_slug', nargs=1, type=str)
parser.add_argument('event_slug', nargs=1, type=str)
parser.add_argument('export_provider', nargs=1, type=str)
parser.add_argument('output_file', nargs=1, type=str)
parser.add_argument('--parameters', action='store', type=str, help='JSON-formatted parameters')
def handle(self, *args, **options):
try:
o = Organizer.objects.get(slug=options['organizer_slug'][0])
except Organizer.DoesNotExist:
self.stderr.write(self.style.ERROR('Organizer not found.'))
sys.exit(1)
with scope(organizer=o):
try:
e = o.events.get(slug=options['event_slug'][0])
except Event.DoesNotExist:
self.stderr.write(self.style.ERROR('Event not found.'))
sys.exit(1)
with language(e.settings.locale), override(e.settings.timezone):
responses = register_data_exporters.send(e)
for receiver, response in responses:
ex = response(e)
if ex.identifier == options['export_provider'][0]:
params = json.loads(options.get('parameters') or '{}')
with open(options['output_file'][0], 'wb') as f:
try:
ex.render(form_data=params, output_file=f)
except TypeError:
self.stderr.write(self.style.WARNING(
'Provider does not support direct file writing, need to buffer export in memory.'))
d = ex.render(form_data=params)
if d is None:
self.stderr.write(self.style.ERROR('Empty export.'))
sys.exit(2)
f.write(d[2])
sys.exit(0)
self.stderr.write(self.style.ERROR('Export provider not found.'))
sys.exit(1)

View File

@@ -2,7 +2,7 @@
Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate" Django tries to be helpful by suggesting to run "makemigrations" in red font on every "migrate"
run when there are things we have no migrations for. Usually, this is intended, and running run when there are things we have no migrations for. Usually, this is intended, and running
"makemigrations" can really screw up the environment of a user, so we want to prevent novice "makemigrations" can really screw up the environment of a user, so we want to prevent novice
users from doing that by going really dirty and fitlering it from the output. users from doing that by going really dirty and filtering it from the output.
""" """
import sys import sys

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.core.management import call_command from django.core.management import call_command
from django.core.management.base import BaseCommand from django.core.management.base import BaseCommand
@@ -8,5 +9,12 @@ class Command(BaseCommand):
help = "Run periodic tasks" help = "Run periodic tasks"
def handle(self, *args, **options): def handle(self, *args, **options):
periodic_task.send(self) for recv, resp in periodic_task.send_robust(self):
if isinstance(resp, Exception):
if settings.SENTRY_ENABLED:
from sentry_sdk import capture_exception
capture_exception(resp)
else:
raise resp
call_command('clearsessions') call_command('clearsessions')

View File

@@ -0,0 +1,39 @@
import sys
from django.apps import apps
from django.core.management import call_command
from django.core.management.base import BaseCommand
from django_scopes import scope, scopes_disabled
class Command(BaseCommand):
def create_parser(self, *args, **kwargs):
parser = super().create_parser(*args, **kwargs)
parser.parse_args = lambda x: parser.parse_known_args(x)[0]
return parser
def handle(self, *args, **options):
parser = self.create_parser(sys.argv[0], sys.argv[1])
flags = parser.parse_known_args(sys.argv[2:])[1]
if "--override" in flags:
with scopes_disabled():
return call_command("shell_plus", *args, **options)
lookups = {}
for flag in flags:
lookup, value = flag.lstrip("-").split("=")
lookup = lookup.split("__", maxsplit=1)
lookups[lookup[0]] = {
lookup[1] if len(lookup) > 1 else "pk": value
}
models = {
model_name.split(".")[-1]: model_class
for app_name, app_content in apps.all_models.items()
for (model_name, model_class) in app_content.items()
}
scope_options = {
app_name: models[app_name].objects.get(**app_value)
for app_name, app_value in lookups.items()
}
with scope(**scope_options):
return call_command("shell_plus", *args, **options)

View File

@@ -0,0 +1,70 @@
# Generated by Django 2.2.1 on 2019-05-30 10:35
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.base
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0122_orderposition_web_secret'),
]
operations = [
migrations.CreateModel(
name='SeatingPlan',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=190)),
('layout', models.TextField()),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seating_plans', to='pretixbase.Organizer')),
],
options={
'abstract': False,
},
bases=(models.Model, pretix.base.models.base.LoggingMixin),
),
migrations.CreateModel(
name='SeatCategoryMapping',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('layout_category', models.CharField(max_length=190)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Event')),
('product', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.Item')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seat_category_mappings', to='pretixbase.SubEvent')),
],
),
migrations.CreateModel(
name='Seat',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=190)),
('blocked', models.BooleanField(default=False)),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Event')),
('product', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.Item')),
('subevent', models.ForeignKey(null=True, on_delete=django.db.models.deletion.CASCADE, related_name='seats', to='pretixbase.SubEvent')),
],
),
migrations.AddField(
model_name='cartposition',
name='seat',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
),
migrations.AddField(
model_name='event',
name='seating_plan',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='events', to='pretixbase.SeatingPlan'),
),
migrations.AddField(
model_name='orderposition',
name='seat',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Seat'),
),
migrations.AddField(
model_name='subevent',
name='seating_plan',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, related_name='subevents', to='pretixbase.SeatingPlan'),
),
]

View File

@@ -0,0 +1,19 @@
# Generated by Django 2.2.1 on 2019-05-30 11:10
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0123_auto_20190530_1035'),
]
operations = [
migrations.AddField(
model_name='seat',
name='seat_guid',
field=models.CharField(db_index=True, default=None, max_length=190),
preserve_default=False,
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 2.2.1 on 2019-07-07 10:10
from django.db import migrations, models
def set_show_hidden_items(apps, schema_editor):
Voucher = apps.get_model('pretixbase', 'Voucher')
Voucher.objects.filter(quota__isnull=False).update(show_hidden_items=False)
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0124_seat_seat_guid'),
]
operations = [
migrations.AddField(
model_name='voucher',
name='show_hidden_items',
field=models.BooleanField(default=True),
),
migrations.RunPython(
set_show_hidden_items,
migrations.RunPython.noop,
)
]

View File

@@ -0,0 +1,18 @@
# Generated by Django 2.2.1 on 2019-07-10 13:45
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0125_voucher_show_hidden_items'),
]
operations = [
migrations.AddField(
model_name='item',
name='show_quota_left',
field=models.NullBooleanField(),
),
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 2.2.1 on 2019-07-11 07:05
from django.db import migrations
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0126_item_show_quota_left'),
]
operations = [
migrations.RenameField(
model_name='question',
old_name='dependency_value',
new_name='dependency_values',
),
migrations.AlterField(
model_name='question',
name='dependency_values',
field=pretix.base.models.fields.MultiStringField(default=['']),
),
]

View File

@@ -0,0 +1,26 @@
# Generated by Django 2.2.1 on 2019-07-15 15:10
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0127_auto_20190711_0705'),
]
operations = [
migrations.AddField(
model_name='quota',
name='close_when_sold_out',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='quota',
name='closed',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 2.2.1 on 2019-07-24 15:48
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0128_auto_20190715_1510'),
]
operations = [
migrations.AddField(
model_name='item',
name='hidden_if_available',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, to='pretixbase.Quota'),
),
]

View File

@@ -0,0 +1,31 @@
# Generated by Django 2.2.1 on 2019-07-29 13:11
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0129_auto_20190724_1548'),
]
operations = [
migrations.AddField(
model_name='seat',
name='row_name',
field=models.CharField(default='', max_length=190),
),
migrations.AddField(
model_name='seat',
name='seat_number',
field=models.CharField(default='', max_length=190),
),
migrations.AddField(
model_name='seat',
name='zone_name',
field=models.CharField(default='', max_length=190),
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 2.2.1 on 2019-07-29 14:22
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0130_auto_20190729_1311'),
]
operations = [
migrations.AddField(
model_name='item',
name='allow_waitinglist',
field=models.BooleanField(default=True),
),
]

View File

@@ -24,6 +24,7 @@ from .orders import (
from .organizer import ( from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
) )
from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule from .tax import TaxRule
from .vouchers import Voucher from .vouchers import Voucher
from .waitinglist import WaitingListEntry from .waitinglist import WaitingListEntry

View File

@@ -12,6 +12,7 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
from django_scopes import scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri from pretix.helpers.urls import build_absolute_uri
@@ -283,6 +284,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True return True
return False return False
@scopes_disabled()
def get_events_with_any_permission(self, request=None): def get_events_with_any_permission(self, request=None):
""" """
Returns a queryset of events the user has any permissions to. Returns a queryset of events the user has any permissions to.
@@ -300,6 +302,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
| Q(id__in=self.teams.values_list('limit_events__id', flat=True)) | Q(id__in=self.teams.values_list('limit_events__id', flat=True))
) )
@scopes_disabled()
def get_events_with_permission(self, permission, request=None): def get_events_with_permission(self, permission, request=None):
""" """
Returns a queryset of events the user has a specific permissions to. Returns a queryset of events the user has a specific permissions to.

View File

@@ -86,7 +86,7 @@ class LoggingMixin:
if (sensitivekey in k) and v: if (sensitivekey in k) and v:
data[k] = "********" data[k] = "********"
logentry.data = json.dumps(data, cls=CustomJSONEncoder) logentry.data = json.dumps(data, cls=CustomJSONEncoder, sort_keys=True)
elif data: elif data:
raise TypeError("You should only supply dictionaries as log data.") raise TypeError("You should only supply dictionaries as log data.")
if save: if save:

View File

@@ -3,6 +3,7 @@ from django.db.models import Case, Count, F, OuterRef, Q, Subquery, When
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
@@ -20,6 +21,8 @@ class CheckinList(LoggedModel):
'order have not been paid. This only works with pretixdesk ' 'order have not been paid. This only works with pretixdesk '
'0.3.0 or newer or pretixdroid 1.9 or newer.')) '0.3.0 or newer or pretixdroid 1.9 or newer.'))
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
ordering = ('subevent__date_from', 'name') ordering = ('subevent__date_from', 'name')
@@ -167,6 +170,8 @@ class Checkin(models.Model):
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT, 'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
) )
objects = ScopedManager(organizer='position__order__event__organizer')
class Meta: class Meta:
unique_together = (('list', 'position'),) unique_together = (('list', 'position'),)

View File

@@ -4,10 +4,12 @@ from django.db import models
from django.db.models import Max from django.db.models import Max
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
@scopes_disabled()
def generate_serial(): def generate_serial():
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16) serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
while Device.objects.filter(unique_serial=serial).exists(): while Device.objects.filter(unique_serial=serial).exists():
@@ -15,6 +17,7 @@ def generate_serial():
return serial return serial
@scopes_disabled()
def generate_initialization_token(): def generate_initialization_token():
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits) token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(initialization_token=token).exists(): while Device.objects.filter(initialization_token=token).exists():
@@ -22,6 +25,7 @@ def generate_initialization_token():
return token return token
@scopes_disabled()
def generate_api_token(): def generate_api_token():
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits) token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(api_token=token).exists(): while Device.objects.filter(api_token=token).exists():
@@ -71,6 +75,8 @@ class Device(LoggedModel):
null=True, blank=True null=True, blank=True
) )
objects = ScopedManager(organizer='organizer')
class Meta: class Meta:
unique_together = (('organizer', 'device_id'),) unique_together = (('organizer', 'device_id'),)

View File

@@ -17,6 +17,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
@@ -99,14 +100,14 @@ class EventMixin:
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" "DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
) )
def get_date_range_display(self, tz=None) -> str: def get_date_range_display(self, tz=None, force_show_end=False) -> str:
""" """
Returns a formatted string containing the start date and the end date Returns a formatted string containing the start date and the end date
of the event with respect to the current locale and to the ``show_times`` and of the event with respect to the current locale and to the ``show_times`` and
``show_date_to`` settings. ``show_date_to`` settings.
""" """
tz = tz or self.timezone tz = tz or self.timezone
if not self.settings.show_date_to or not self.date_to: if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
return _date(self.date_from.astimezone(tz), "DATE_FORMAT") return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz)) return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
@@ -335,6 +336,10 @@ class Event(EventMixin, LoggedModel):
verbose_name=_('Event series'), verbose_name=_('Event series'),
default=False default=False
) )
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
objects = ScopedManager(organizer='organizer')
class Meta: class Meta:
verbose_name = _("Event") verbose_name = _("Event")
@@ -345,6 +350,26 @@ class Event(EventMixin, LoggedModel):
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@property @property
def presale_has_ended(self): def presale_has_ended(self):
if self.has_subevents: if self.has_subevents:
@@ -491,14 +516,21 @@ class Event(EventMixin, LoggedModel):
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
items = list(q.items.all()) items = list(q.items.all())
vars = list(q.variations.all()) vars = list(q.variations.all())
oldid = q.pk
q.pk = None q.pk = None
q.event = self q.event = self
q.cached_availability_state = None
q.cached_availability_number = None
q.cached_availability_paid_orders = None
q.cached_availability_time = None
q.closed = False
q.save() q.save()
for i in items: for i in items:
if i.pk in item_map: if i.pk in item_map:
q.items.add(item_map[i.pk]) q.items.add(item_map[i.pk])
for v in vars: for v in vars:
q.variations.add(variation_map[v.pk]) q.variations.add(variation_map[v.pk])
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
question_map = {} question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
@@ -528,6 +560,24 @@ class Event(EventMixin, LoggedModel):
for i in items: for i in items:
cl.limit_products.add(item_map[i.pk]) cl.limit_products.add(item_map[i.pk])
if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id:
self.seating_plan = other.seating_plan
else:
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
self.save()
for m in other.seat_category_mappings.filter(subevent__isnull=True):
m.pk = None
m.event = self
m.product = item_map[m.product_id]
m.save()
for s in other.seats.filter(subevent__isnull=True):
s.pk = None
s.event = self
s.save()
for s in other.settings._objects.all(): for s in other.settings._objects.all():
s.object = self s.object = self
s.pk = None s.pk = None
@@ -667,8 +717,12 @@ class Event(EventMixin, LoggedModel):
@property @property
def meta_data(self): def meta_data(self):
data = {p.name: p.default for p in self.organizer.meta_properties.all()} data = {p.name: p.default for p in self.organizer.meta_properties.all()}
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()}) if hasattr(self, 'meta_values_cached'):
return data data.update({v.property.name: v.value for v in self.meta_values_cached})
else:
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
@property @property
def has_payment_provider(self): def has_payment_provider(self):
@@ -871,10 +925,14 @@ class SubEvent(EventMixin, LoggedModel):
null=True, blank=True, null=True, blank=True,
verbose_name=_("Frontpage text") verbose_name=_("Frontpage text")
) )
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents')
items = models.ManyToManyField('Item', through='SubEventItem') items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation') variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Date in event series") verbose_name = _("Date in event series")
verbose_name_plural = _("Dates in event series") verbose_name_plural = _("Dates in event series")
@@ -883,6 +941,28 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self): def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display()) return '{} - {}'.format(self.name, self.get_date_range_display())
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@cached_property @cached_property
def settings(self): def settings(self):
return self.event.settings return self.event.settings
@@ -941,6 +1021,7 @@ class SubEvent(EventMixin, LoggedModel):
raise ValidationError(_('One or more variations do not belong to this event.')) raise ValidationError(_('One or more variations do not belong to this event.'))
@scopes_disabled()
def generate_invite_token(): def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)

View File

@@ -9,6 +9,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import pgettext from django.utils.translation import pgettext
from django_countries.fields import CountryField from django_countries.fields import CountryField
from django_scopes import ScopedManager
def invoice_filename(instance, filename: str) -> str: def invoice_filename(instance, filename: str) -> str:
@@ -107,6 +108,8 @@ class Invoice(models.Model):
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255) file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
internal_reference = models.TextField(blank=True) internal_reference = models.TextField(blank=True)
objects = ScopedManager(organizer='event__organizer')
@staticmethod @staticmethod
def _to_numeric_invoice_number(number): def _to_numeric_invoice_number(number):
return '{:05d}'.format(int(number)) return '{:05d}'.format(int(number))
@@ -172,6 +175,8 @@ class Invoice(models.Model):
self.organizer = self.order.event.organizer self.organizer = self.order.event.organizer
if not self.prefix: if not self.prefix:
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-') self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
if self.is_cancellation:
self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
if not self.invoice_no: if not self.invoice_no:
if self.order.testmode: if self.order.testmode:
self.prefix += 'TEST-' self.prefix += 'TEST-'

View File

@@ -17,11 +17,14 @@ from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now from django.utils.timezone import is_naive, make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import ScopedManager
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models import fields from pretix.base.models import fields
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
from pretix.base.models.fields import MultiStringField
from pretix.base.models.tax import TaxedPrice from pretix.base.models.tax import TaxedPrice
from pretix.base.signals import quota_availability
from .event import Event, SubEvent from .event import Event, SubEvent
@@ -155,28 +158,43 @@ class SubEventItemVariation(models.Model):
self.subevent.event.cache.clear() self.subevent.event.cache.clear()
def filter_available(qs, channel='web', voucher=None, allow_addons=False):
q = (
# IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated()
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
if voucher:
if voucher.item_id:
q &= Q(pk=voucher.item_id)
elif voucher.quota_id:
q &= Q(quotas__in=[voucher.quota_id])
else:
return qs.none()
if not voucher or not voucher.show_hidden_items:
q &= Q(hide_without_voucher=False)
return qs.filter(q)
class ItemQuerySet(models.QuerySet): class ItemQuerySet(models.QuerySet):
def filter_available(self, channel='web', voucher=None, allow_addons=False): def filter_available(self, channel='web', voucher=None, allow_addons=False):
q = ( return filter_available(self, channel, voucher, allow_addons)
# IMPORTANT: If this is updated, also update the ItemVariation query
# in models/event.py: EventMixin.annotated()
Q(active=True)
& Q(Q(available_from__isnull=True) | Q(available_from__lte=now()))
& Q(Q(available_until__isnull=True) | Q(available_until__gte=now()))
& Q(sales_channels__contains=channel) & Q(require_bundling=False)
)
if not allow_addons:
q &= Q(Q(category__isnull=True) | Q(category__is_addon=False))
qs = self.filter(q)
vouchq = Q(hide_without_voucher=False)
if voucher: class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
if voucher.item_id: def __init__(self):
vouchq |= Q(pk=voucher.item_id) super().__init__()
qs = qs.filter(pk=voucher.item_id) self._queryset_class = ItemQuerySet
elif voucher.quota_id:
qs = qs.filter(quotas__in=[voucher.quota_id]) def filter_available(self, channel='web', voucher=None, allow_addons=False):
return qs.filter(vouchq) return filter_available(self.get_queryset(), channel, voucher, allow_addons)
class Item(LoggedModel): class Item(LoggedModel):
@@ -226,7 +244,7 @@ class Item(LoggedModel):
:type sales_channels: bool :type sales_channels: bool
""" """
objects = ItemQuerySet.as_manager() objects = ItemQuerySetManager()
event = models.ForeignKey( event = models.ForeignKey(
Event, Event,
@@ -293,6 +311,16 @@ class Item(LoggedModel):
verbose_name=_("Generate tickets"), verbose_name=_("Generate tickets"),
blank=True, null=True, blank=True, null=True,
) )
allow_waitinglist = models.BooleanField(
verbose_name=_("Show a waiting list for this ticket"),
help_text=_("This will only work of waiting lists are enabled for this event."),
default=True
)
show_quota_left = models.NullBooleanField(
verbose_name=_("Show number of tickets left"),
help_text=_("Publicly show how many tickets are still available."),
blank=True, null=True,
)
position = models.IntegerField( position = models.IntegerField(
default=0 default=0
) )
@@ -311,6 +339,17 @@ class Item(LoggedModel):
null=True, blank=True, null=True, blank=True,
help_text=_('This product will not be sold after the given date.') help_text=_('This product will not be sold after the given date.')
) )
hidden_if_available = models.ForeignKey(
'Quota',
null=True, blank=True,
on_delete=models.SET_NULL,
verbose_name=_("Only show after sellout of"),
help_text=_("If you select a quota here, this product will only be shown when that quota is "
"unavailable. If combined with the option to hide sold-out products, this allows you to "
"swap out products for more expensive ones once they are sold out. There might be a short period "
"in which both products are visible while all tickets in the referenced quota are reserved, "
"but not yet sold.")
)
require_voucher = models.BooleanField( require_voucher = models.BooleanField(
verbose_name=_('This product can only be bought using a voucher.'), verbose_name=_('This product can only be bought using a voucher.'),
default=False, default=False,
@@ -328,7 +367,7 @@ class Item(LoggedModel):
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'), verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
default=False, default=False,
help_text=_('This product will be hidden from the event page until the user enters a voucher ' help_text=_('This product will be hidden from the event page until the user enters a voucher '
'code that is specifically tied to this product (and not via a quota).') 'that unlocks this product.')
) )
require_bundling = models.BooleanField( require_bundling = models.BooleanField(
verbose_name=_('Only sell this product as part of a bundle'), verbose_name=_('Only sell this product as part of a bundle'),
@@ -391,10 +430,17 @@ class Item(LoggedModel):
self.event.cache.clear() self.event.cache.clear()
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.vouchers.update(item=None, variation=None, quota=None)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.event: if self.event:
self.event.cache.clear() self.event.cache.clear()
@property
def do_show_quota_left(self):
if self.show_quota_left is None:
return self.event.settings.show_quota_left
return self.show_quota_left
def tax(self, price=None, base_price_is='auto', currency=None, include_bundled=False): def tax(self, price=None, base_price_is='auto', currency=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
@@ -447,7 +493,7 @@ class Item(LoggedModel):
return check_quotas return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None, def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False): include_bundled=False, trust_parameters=False, fail_on_no_quotas=False):
""" """
This method is used to determine whether this Item is currently available This method is used to determine whether this Item is currently available
for sale. for sale.
@@ -495,6 +541,8 @@ class Item(LoggedModel):
res = (code_avail, num_avail) res = (code_avail, num_avail)
if len(quotacounter) == 0: if len(quotacounter) == 0:
if fail_on_no_quotas:
return Quota.AVAILABILITY_GONE, 0
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res return res
@@ -591,6 +639,8 @@ class ItemVariation(models.Model):
'discounted one. This is just a cosmetic setting and will not actually impact pricing.') 'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
) )
objects = ScopedManager(organizer='item__event__organizer')
class Meta: class Meta:
verbose_name = _("Product variation") verbose_name = _("Product variation")
verbose_name_plural = _("Product variations") verbose_name_plural = _("Product variations")
@@ -627,6 +677,7 @@ class ItemVariation(models.Model):
return t return t
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.vouchers.update(item=None, variation=None, quota=None)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.item: if self.item:
self.item.event.cache.clear() self.item.event.cache.clear()
@@ -647,7 +698,7 @@ class ItemVariation(models.Model):
return check_quotas return check_quotas
def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None, def check_quotas(self, ignored_quotas=None, count_waitinglist=True, subevent=None, _cache=None,
include_bundled=False, trust_parameters=False) -> Tuple[int, int]: include_bundled=False, trust_parameters=False, fail_on_no_quotas=False) -> Tuple[int, int]:
""" """
This method is used to determine whether this ItemVariation is currently This method is used to determine whether this ItemVariation is currently
available for sale in terms of quotas. available for sale in terms of quotas.
@@ -689,6 +740,8 @@ class ItemVariation(models.Model):
if code_avail < res[0] or res[1] is None or num_avail < res[1]: if code_avail < res[0] or res[1] is None or num_avail < res[1]:
res = (code_avail, num_avail) res = (code_avail, num_avail)
if len(quotacounter) == 0: if len(quotacounter) == 0:
if fail_on_no_quotas:
return Quota.AVAILABILITY_GONE, 0
return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility return Quota.AVAILABILITY_OK, sys.maxsize # backwards compatibility
return res return res
@@ -902,8 +955,8 @@ class Question(LoggedModel):
:type identifier: str :type identifier: str
:param dependency_question: This question will only show up if the referenced question is set to `dependency_value`. :param dependency_question: This question will only show up if the referenced question is set to `dependency_value`.
:type dependency_question: Question :type dependency_question: Question
:param dependency_value: The value that `dependency_question` needs to be set to for this question to be applicable. :param dependency_values: The values that `dependency_question` needs to be set to for this question to be applicable.
:type dependency_value: str :type dependency_values: list[str]
""" """
TYPE_NUMBER = "N" TYPE_NUMBER = "N"
TYPE_STRING = "S" TYPE_STRING = "S"
@@ -983,7 +1036,9 @@ class Question(LoggedModel):
dependency_question = models.ForeignKey( dependency_question = models.ForeignKey(
'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions' 'Question', null=True, blank=True, on_delete=models.SET_NULL, related_name='dependent_questions'
) )
dependency_value = models.TextField(null=True, blank=True) dependency_values = MultiStringField(default=[])
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Question") verbose_name = _("Question")
@@ -1234,6 +1289,17 @@ class Quota(LoggedModel):
cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True) cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True)
cached_availability_time = models.DateTimeField(null=True, blank=True) cached_availability_time = models.DateTimeField(null=True, blank=True)
close_when_sold_out = models.BooleanField(
verbose_name=_('Close this quota permanently once it is sold out'),
help_text=_('If you enable this, when the quota is sold out once, no more tickets will be sold, '
'even if tickets become available again through cancellations or expiring orders. Of course, '
'you can always re-open it manually.'),
default=False
)
closed = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Quota") verbose_name = _("Quota")
verbose_name_plural = _("Quotas") verbose_name_plural = _("Quotas")
@@ -1243,6 +1309,7 @@ class Quota(LoggedModel):
return self.name return self.name
def delete(self, *args, **kwargs): def delete(self, *args, **kwargs):
self.vouchers.update(item=None, variation=None, quota=None)
super().delete(*args, **kwargs) super().delete(*args, **kwargs)
if self.event: if self.event:
self.event.cache.clear() self.event.cache.clear()
@@ -1292,6 +1359,14 @@ class Quota(LoggedModel):
return _cache[self.pk] return _cache[self.pk]
now_dt = now_dt or now() now_dt = now_dt or now()
res = self._availability(now_dt, count_waitinglist) res = self._availability(now_dt, count_waitinglist)
for recv, resp in quota_availability.send(sender=self.event, quota=self, result=res,
count_waitinglist=count_waitinglist):
res = resp
if res[0] <= Quota.AVAILABILITY_ORDERED and self.close_when_sold_out and not self.closed:
self.closed = True
self.save(update_fields=['closed'])
self.log_action('pretix.event.quota.closed')
self.event.cache.delete('item_quota_cache') self.event.cache.delete('item_quota_cache')
rewrite_cache = count_waitinglist and ( rewrite_cache = count_waitinglist and (
@@ -1317,8 +1392,11 @@ class Quota(LoggedModel):
_cache['_count_waitinglist'] = count_waitinglist _cache['_count_waitinglist'] = count_waitinglist
return res return res
def _availability(self, now_dt: datetime=None, count_waitinglist=True): def _availability(self, now_dt: datetime=None, count_waitinglist=True, ignore_closed=False):
now_dt = now_dt or now() now_dt = now_dt or now()
if self.closed and not ignore_closed:
return Quota.AVAILABILITY_ORDERED, 0
size_left = self.size size_left = self.size
if size_left is None: if size_left is None:
return Quota.AVAILABILITY_OK, None return Quota.AVAILABILITY_OK, None

View File

@@ -15,7 +15,7 @@ from django.db import models, transaction
from django.db.models import ( from django.db.models import (
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When, Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce, Greatest
from django.db.models.signals import post_delete from django.db.models.signals import post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@@ -26,6 +26,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField from jsonfallback.fields import FallbackJSONField
@@ -186,6 +187,8 @@ class Order(LockModel, LoggedModel):
verbose_name=_('E-mail address verified') verbose_name=_('E-mail address verified')
) )
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Order") verbose_name = _("Order")
verbose_name_plural = _("Orders") verbose_name_plural = _("Orders")
@@ -195,6 +198,8 @@ class Order(LockModel, LoggedModel):
return self.full_code return self.full_code
def gracefully_delete(self, user=None, auth=None): def gracefully_delete(self, user=None, auth=None):
from . import Voucher
if not self.testmode: if not self.testmode:
raise TypeError("Only test mode orders can be deleted.") raise TypeError("Only test mode orders can be deleted.")
self.event.log_action( self.event.log_action(
@@ -203,6 +208,12 @@ class Order(LockModel, LoggedModel):
'code': self.code, 'code': self.code,
} }
) )
if self.status != Order.STATUS_CANCELED:
for position in self.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete() OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete()
@@ -223,6 +234,7 @@ class Order(LockModel, LoggedModel):
return self.all_fees(manager='objects') return self.all_fees(manager='objects')
@cached_property @cached_property
@scopes_disabled()
def count_positions(self): def count_positions(self):
if hasattr(self, 'pcnt'): if hasattr(self, 'pcnt'):
return self.pcnt or 0 return self.pcnt or 0
@@ -246,6 +258,7 @@ class Order(LockModel, LoggedModel):
return None return None
@property @property
@scopes_disabled()
def payment_refund_sum(self): def payment_refund_sum(self):
payment_sum = self.payments.filter( payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
@@ -257,6 +270,7 @@ class Order(LockModel, LoggedModel):
return payment_sum - refund_sum return payment_sum - refund_sum
@property @property
@scopes_disabled()
def pending_sum(self): def pending_sum(self):
total = self.total total = self.total
if self.status == Order.STATUS_CANCELED: if self.status == Order.STATUS_CANCELED:
@@ -431,6 +445,7 @@ class Order(LockModel, LoggedModel):
return round_decimal(fee, self.event.currency) return round_decimal(fee, self.event.currency)
@property @property
@scopes_disabled()
def user_cancel_allowed(self) -> bool: def user_cancel_allowed(self) -> bool:
""" """
Returns whether or not this order can be canceled by the user. Returns whether or not this order can be canceled by the user.
@@ -615,7 +630,7 @@ class Order(LockModel, LoggedModel):
), tz) ), tz)
return term_last return term_last
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]: def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]:
error_messages = { error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " 'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."), "payment settings is over."),
@@ -623,29 +638,37 @@ class Order(LockModel, LoggedModel):
"payments should be accepted in the payment settings."), "payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.') 'require_approval': _('This order is not yet approved by the event organizer.')
} }
if self.require_approval: if not force:
return error_messages['require_approval'] if self.require_approval:
term_last = self.payment_term_last return error_messages['require_approval']
if term_last and not ignore_date: term_last = self.payment_term_last
if now() > term_last: if term_last and not ignore_date:
return error_messages['late_lastdate'] if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING: if self.status == self.STATUS_PENDING:
return True return True
if not self.event.settings.get('payment_term_accept_late') and not ignore_date: if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force:
return error_messages['late'] return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist) return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]: def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
error_messages = { error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'), 'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
} }
now_dt = now_dt or now() now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation') positions = self.positions.all().select_related('item', 'variation', 'seat')
quota_cache = {} quota_cache = {}
try: try:
for i, op in enumerate(positions): for i, op in enumerate(positions):
if op.seat:
if not op.seat.is_available(ignore_orderpos=op):
raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat))
if force:
continue
quotas = list(op.quotas) quotas = list(op.quotas)
if len(quotas) == 0: if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format( raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -814,6 +837,8 @@ class QuestionAnswer(models.Model):
max_length=255 max_length=255
) )
objects = ScopedManager(organizer='question__event__organizer')
@property @property
def backend_file_url(self): def backend_file_url(self):
if self.file: if self.file:
@@ -845,6 +870,10 @@ class QuestionAnswer(models.Model):
return url return url
return "" return ""
@property
def is_image(self):
return any(self.file.name.endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg'))
@property @property
def file_name(self): def file_name(self):
return self.file.name.split('.', 1)[-1] return self.file.name.split('.', 1)[-1]
@@ -921,6 +950,8 @@ class AbstractPosition(models.Model):
:type voucher: Voucher :type voucher: Voucher
:param meta_info: Additional meta information on the position, JSON-encoded. :param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str :type meta_info: str
:param seat: Seat, if reserved seating is used.
:type seat: Seat
""" """
subevent = models.ForeignKey( subevent = models.ForeignKey(
SubEvent, SubEvent,
@@ -967,6 +998,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Meta information"), verbose_name=_("Meta information"),
null=True, blank=True null=True, blank=True
) )
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
class Meta: class Meta:
abstract = True abstract = True
@@ -1008,18 +1042,17 @@ class AbstractPosition(models.Model):
q.pk: q for q in questions q.pk: q for q in questions
} }
def question_is_visible(parentid, qval): def question_is_visible(parentid, qvals):
parentq = question_cache[parentid] parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value): if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False return False
if parentid not in self.answ: if parentid not in self.answ:
return False return False
if qval == 'True': return (
return self.answ[parentid].answer == 'True' ('True' in qvals and self.answ[parentid].answer == 'True')
elif qval == 'False': or ('False' in qvals and self.answ[parentid].answer == 'False')
return self.answ[parentid].answer == 'False' or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
else: )
return qval in [o.identifier for o in self.answ[parentid].options.all()]
self.questions = [] self.questions = []
for q in questions: for q in questions:
@@ -1028,7 +1061,7 @@ class AbstractPosition(models.Model):
q.answer.question = q # cache object q.answer.question = q # cache object
else: else:
q.answer = "" q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value): if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
self.questions.append(q) self.questions.append(q)
@property @property
@@ -1137,6 +1170,8 @@ class OrderPayment(models.Model):
) )
migrated = models.BooleanField(default=False) migrated = models.BooleanField(default=False)
objects = ScopedManager(organizer='order__event__organizer')
class Meta: class Meta:
ordering = ('local_id',) ordering = ('local_id',)
@@ -1153,7 +1188,7 @@ class OrderPayment(models.Model):
@info_data.setter @info_data.setter
def info_data(self, d): def info_data(self, d):
self.info = json.dumps(d) self.info = json.dumps(d, sort_keys=True)
@cached_property @cached_property
def payment_provider(self): def payment_provider(self):
@@ -1164,8 +1199,8 @@ class OrderPayment(models.Model):
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False): def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date) can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
if not force and can_be_paid is not True: if can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', { self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid 'message': can_be_paid
}, user=user, auth=auth) }, user=user, auth=auth)
@@ -1493,6 +1528,8 @@ class OrderRefund(models.Model):
null=True, blank=True null=True, blank=True
) )
objects = ScopedManager(organizer='order__event__organizer')
class Meta: class Meta:
ordering = ('local_id',) ordering = ('local_id',)
@@ -1509,7 +1546,7 @@ class OrderRefund(models.Model):
@info_data.setter @info_data.setter
def info_data(self, d): def info_data(self, d):
self.info = json.dumps(d) self.info = json.dumps(d, sort_keys=True)
@cached_property @cached_property
def payment_provider(self): def payment_provider(self):
@@ -1554,7 +1591,7 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class ActivePositionManager(models.Manager): class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(canceled=False) return super().get_queryset().filter(canceled=False)
@@ -1631,7 +1668,7 @@ class OrderFee(models.Model):
) )
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = models.Manager() all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager() objects = ActivePositionManager()
@property @property
@@ -1736,7 +1773,7 @@ class OrderPosition(AbstractPosition):
) )
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = models.Manager() all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager() objects = ActivePositionManager()
class Meta: class Meta:
@@ -1836,6 +1873,7 @@ class OrderPosition(AbstractPosition):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@scopes_disabled()
def assign_pseudonymization_id(self): def assign_pseudonymization_id(self):
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0) # This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in # and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
@@ -1943,6 +1981,8 @@ class CartPosition(AbstractPosition):
) )
is_bundled = models.BooleanField(default=False) is_bundled = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Cart position") verbose_name = _("Cart position")
verbose_name_plural = _("Cart positions") verbose_name_plural = _("Cart positions")
@@ -1992,6 +2032,8 @@ class InvoiceAddress(models.Model):
blank=True blank=True
) )
objects = ScopedManager(organizer='order__event__organizer')
def save(self, **kwargs): def save(self, **kwargs):
if self.order: if self.order:
self.order.touch() self.order.touch()

View File

@@ -0,0 +1,124 @@
import json
from collections import namedtuple
import jsonschema
from django.contrib.staticfiles import finders
from django.core.exceptions import ValidationError
from django.db import models
from django.utils.deconstruct import deconstructible
from django.utils.timezone import now
from django.utils.translation import gettext, ugettext_lazy as _
from pretix.base.models import Event, Item, LoggedModel, Organizer, SubEvent
@deconstructible
class SeatingPlanLayoutValidator:
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('seating/seating-plan.schema.json'), 'r') as f:
schema = json.loads(f.read())
try:
jsonschema.validate(val, schema)
except jsonschema.ValidationError as e:
raise ValidationError(_('Your layout file is not a valid seating plan. Error message: {}').format(str(e)))
class SeatingPlan(LoggedModel):
"""
Represents an abstract seating plan, without relation to any event.
"""
name = models.CharField(max_length=190, verbose_name=_('Name'))
organizer = models.ForeignKey(Organizer, related_name='seating_plans', on_delete=models.CASCADE)
layout = models.TextField(validators=[SeatingPlanLayoutValidator()])
Category = namedtuple('Categrory', 'name')
RawSeat = namedtuple('Seat', 'name guid number row category zone')
def __str__(self):
return self.name
@property
def layout_data(self):
return json.loads(self.layout)
@layout_data.setter
def layout_data(self, v):
self.layout = json.dumps(v)
def get_categories(self):
return [
self.Category(name=c['name'])
for c in self.layout_data['categories']
]
def iter_all_seats(self):
for z in self.layout_data['zones']:
for r in z['rows']:
for s in r['seats']:
yield self.RawSeat(
number=s['seat_number'],
guid=s['seat_guid'],
name='{} {}'.format(r['row_number'], s['seat_number']), # TODO: Zone? Variable scheme?
row=r['row_number'],
zone=z['name'],
category=s['category']
)
class SeatCategoryMapping(models.Model):
"""
Input seating plans have abstract "categories", such as "Balcony seat", etc. This model maps them to actual
pretix product on a per-(sub)event level.
"""
event = models.ForeignKey(Event, related_name='seat_category_mappings', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seat_category_mappings', on_delete=models.CASCADE)
layout_category = models.CharField(max_length=190)
product = models.ForeignKey(Item, related_name='seat_category_mappings', on_delete=models.CASCADE)
class Seat(models.Model):
"""
This model is used to represent every single specific seat within an (sub)event that can be selected. It's mainly
used for internal bookkeeping and not to be modified by users directly.
"""
event = models.ForeignKey(Event, related_name='seats', on_delete=models.CASCADE)
subevent = models.ForeignKey(SubEvent, null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
name = models.CharField(max_length=190)
zone_name = models.CharField(max_length=190, blank=True, default="")
row_name = models.CharField(max_length=190, blank=True, default="")
seat_number = models.CharField(max_length=190, blank=True, default="")
seat_guid = models.CharField(max_length=190, db_index=True)
product = models.ForeignKey('Item', null=True, blank=True, related_name='seats', on_delete=models.CASCADE)
blocked = models.BooleanField(default=False)
def __str__(self):
parts = []
if self.zone_name:
parts.append(self.zone_name)
if self.row_name:
parts.append(gettext('Row {number}').format(number=self.row_name))
if self.seat_number:
parts.append(gettext('Seat {number}').format(number=self.seat_number))
if not parts:
return self.name
return ', '.join(parts)
def is_available(self, ignore_cart=None, ignore_orderpos=None):
from .orders import Order
if self.blocked:
return False
opqs = self.orderposition_set.filter(order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID])
cpqs = self.cartposition_set.filter(expires__gte=now())
if ignore_cart:
cpqs = cpqs.exclude(pk=ignore_cart.pk)
if ignore_orderpos:
opqs = opqs.exclude(pk=ignore_orderpos.pk)
return not opqs.exists() and not cpqs.exists()

View File

@@ -8,6 +8,9 @@ from django.db.models import Q
from django.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import SeatCategoryMapping
from ..decimal import round_decimal from ..decimal import round_decimal
from .base import LoggedModel from .base import LoggedModel
@@ -23,6 +26,7 @@ def _generate_random_code(prefix=None):
return get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset) return get_random_string(length=settings.ENTROPY['voucher_code'], allowed_chars=charset)
@scopes_disabled()
def generate_code(prefix=None): def generate_code(prefix=None):
while True: while True:
code = _generate_random_code(prefix=prefix) code = _generate_random_code(prefix=prefix)
@@ -138,22 +142,26 @@ class Voucher(LoggedModel):
item = models.ForeignKey( item = models.ForeignKey(
Item, related_name='vouchers', Item, related_name='vouchers',
verbose_name=_("Product"), verbose_name=_("Product"),
null=True, blank=True, on_delete=models.CASCADE, null=True, blank=True,
on_delete=models.PROTECT, # We use a fake version of SET_NULL in Item.delete()
help_text=_( help_text=_(
"This product is added to the user's cart if the voucher is redeemed." "This product is added to the user's cart if the voucher is redeemed."
) )
) )
variation = models.ForeignKey( variation = models.ForeignKey(
ItemVariation, related_name='vouchers', ItemVariation, related_name='vouchers',
null=True, blank=True, on_delete=models.CASCADE, null=True, blank=True,
on_delete=models.PROTECT, # We use a fake version of SET_NULL in ItemVariation.delete() to avoid the semantic change
# that would happen if we just set variation to None
verbose_name=_("Product variation"), verbose_name=_("Product variation"),
help_text=_( help_text=_(
"This variation of the product select above is being used." "This variation of the product select above is being used."
) )
) )
quota = models.ForeignKey( quota = models.ForeignKey(
Quota, related_name='quota', Quota, related_name='vouchers',
null=True, blank=True, on_delete=models.CASCADE, null=True, blank=True,
on_delete=models.PROTECT, # We use a fake version of SET_NULL in Quota.delete()
verbose_name=_("Quota"), verbose_name=_("Quota"),
help_text=_( help_text=_(
"If enabled, the voucher is valid for any product affected by this quota." "If enabled, the voucher is valid for any product affected by this quota."
@@ -172,6 +180,12 @@ class Voucher(LoggedModel):
help_text=_("The text entered in this field will not be visible to the user and is available for your " help_text=_("The text entered in this field will not be visible to the user and is available for your "
"convenience.") "convenience.")
) )
show_hidden_items = models.BooleanField(
verbose_name=_("Shows hidden products that match this voucher"),
default=True
)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Voucher") verbose_name = _("Voucher")
@@ -391,3 +405,14 @@ class Voucher(LoggedModel):
""" """
return Order.objects.filter(all_positions__voucher__in=[self]).distinct() return Order.objects.filter(all_positions__voucher__in=[self]).distinct()
def seating_available(self):
kwargs = {}
if self.subevent:
kwargs['subevent'] = self.subevent
if self.quota_id:
return SeatCategoryMapping.objects.filter(product__quotas__pk=self.quota_id, **kwargs).exists()
elif self.item_id:
return self.item.seat_category_mappings.filter(**kwargs).exists()
else:
return False

View File

@@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Voucher from pretix.base.models import Voucher
@@ -67,6 +68,8 @@ class WaitingListEntry(LoggedModel):
) )
priority = models.IntegerField(default=0) priority = models.IntegerField(default=0)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Waiting list entry") verbose_name = _("Waiting list entry")
verbose_name_plural = _("Waiting list entries") verbose_name_plural = _("Waiting list entries")

View File

@@ -249,9 +249,7 @@ class BasePaymentProvider:
('_fee_percent', ('_fee_percent',
forms.DecimalField( forms.DecimalField(
label=_('Additional fee'), label=_('Additional fee'),
help_text=_('Percentage of the order total. Note that this percentage will currently only ' help_text=_('Percentage of the order total.'),
'be calculated on the summed price of sold tickets, not on other fees like e.g. shipping '
'fees, if there are any.'),
localize=True, localize=True,
required=False, required=False,
)), )),
@@ -298,11 +296,12 @@ class BasePaymentProvider:
""" """
return "" return ""
def render_invoice_text(self, order: Order) -> str: def render_invoice_text(self, order: Order, payment: OrderPayment) -> str:
""" """
This is called when an invoice for an order with this payment provider is generated. This is called when an invoice for an order with this payment provider is generated.
The default implementation returns the content of the _invoice_text configuration The default implementation returns the content of the _invoice_text configuration
variable (an I18nString), or an empty string if unconfigured. variable (an I18nString), or an empty string if unconfigured. For paid orders, the
default implementation always renders a string stating that the invoice is already paid.
""" """
if order.status == Order.STATUS_PAID: if order.status == Order.STATUS_PAID:
return pgettext_lazy('invoice', 'The payment for this invoice has already been received.') return pgettext_lazy('invoice', 'The payment for this invoice has already been received.')
@@ -547,13 +546,14 @@ class BasePaymentProvider:
""" """
return None return None
def order_pending_mail_render(self, order: Order) -> str: def order_pending_mail_render(self, order: Order, payment: OrderPayment) -> str:
""" """
After the user has submitted their order, they will receive a confirmation After the user has submitted their order, they will receive a confirmation
email. You can return a string from this method if you want to add additional email. You can return a string from this method if you want to add additional
information to this email. information to this email.
:param order: The order object :param order: The order object
:param payment: The payment object
""" """
return "" return ""

View File

@@ -114,7 +114,7 @@ DEFAULT_VARIABLES = OrderedDict((
("event_date_range", { ("event_date_range", {
"label": _("Event date range"), "label": _("Event date range"),
"editor_sample": _("May 31st June 4th, 2017"), "editor_sample": _("May 31st June 4th, 2017"),
"evaluate": lambda op, order, ev: ev.get_date_range_display() "evaluate": lambda op, order, ev: ev.get_date_range_display(force_show_end=True)
}), }),
("event_begin", { ("event_begin", {
"label": _("Event begin date and time"), "label": _("Event begin date and time"),
@@ -238,6 +238,26 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT" "TIME_FORMAT"
) if ev.date_admission else "" ) if ev.date_admission else ""
}), }),
("seat", {
"label": _("Seat: Full name"),
"editor_sample": _("Ground floor, Row 3, Seat 4"),
"evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission'))
}),
("seat_zone", {
"label": _("Seat: zone"),
"editor_sample": _("Ground floor"),
"evaluate": lambda op, order, ev: str(op.seat.zone_name if op.seat else _('General admission'))
}),
("seat_row", {
"label": _("Seat: row"),
"editor_sample": "3",
"evaluate": lambda op, order, ev: str(op.seat.row_name if op.seat else "")
}),
("seat_number", {
"label": _("Seat: seat number"),
"editor_sample": 4,
"evaluate": lambda op, order, ev: str(op.seat.seat_number if op.seat else "")
}),
)) ))

View File

@@ -6,15 +6,16 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db import DatabaseError, transaction from django.db import DatabaseError, transaction
from django.db.models import Q from django.db.models import Count, Exists, OuterRef, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _ from django.utils.translation import pgettext_lazy, ugettext as _
from django_scopes import scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
Voucher, SeatCategoryMapping, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee from pretix.base.models.orders import OrderFee
@@ -23,8 +24,9 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import validate_cart_addons
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.celery_app import app from pretix.celery_app import app
from pretix.presale.signals import ( from pretix.presale.signals import (
@@ -90,15 +92,20 @@ error_messages = {
'product %(base)s.'), 'product %(base)s.'),
'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'), 'addon_only': _('One of the products you selected can only be bought as an add-on to another project.'),
'bundled_only': _('One of the products you selected can only be bought part of a bundle.'), 'bundled_only': _('One of the products you selected can only be bought part of a bundle.'),
'seat_required': _('You need to select a specific seat.'),
'seat_invalid': _('Please select a valid seat.'),
'seat_forbidden': _('You can not select a seat for this position.'),
'seat_unavailable': _('The seat you selected has already been taken. Please select a different seat.'),
'seat_multiple': _('You can not select the same seat multiple times.'),
} }
class CartManager: class CartManager:
AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas', AddOperation = namedtuple('AddOperation', ('count', 'item', 'variation', 'price', 'voucher', 'quotas',
'addon_to', 'subevent', 'includes_tax', 'bundled')) 'addon_to', 'subevent', 'includes_tax', 'bundled', 'seat'))
RemoveOperation = namedtuple('RemoveOperation', ('position',)) RemoveOperation = namedtuple('RemoveOperation', ('position',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher', ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent')) 'quotas', 'subevent', 'seat'))
order = { order = {
RemoveOperation: 10, RemoveOperation: 10,
ExtendOperation: 20, ExtendOperation: 20,
@@ -116,6 +123,7 @@ class CartManager:
self._items_cache = {} self._items_cache = {}
self._subevents_cache = {} self._subevents_cache = {}
self._variations_cache = {} self._variations_cache = {}
self._seated_cache = {}
self._expiry = None self._expiry = None
self.invoice_address = invoice_address self.invoice_address = invoice_address
self._widget_data = widget_data or {} self._widget_data = widget_data or {}
@@ -127,6 +135,11 @@ class CartManager:
Q(cart_id=self.cart_id) & Q(event=self.event) Q(cart_id=self.cart_id) & Q(event=self.event)
).select_related('item', 'subevent') ).select_related('item', 'subevent')
def _is_seated(self, item, subevent):
if (item, subevent) not in self._seated_cache:
self._seated_cache[item, subevent] = item.seat_category_mappings.filter(subevent=subevent).exists()
return self._seated_cache[item, subevent]
def _calculate_expiry(self): def _calculate_expiry(self):
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int)) self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
@@ -187,6 +200,8 @@ class CartManager:
i.pk: i i.pk: i
for i in self.event.items.select_related('category').prefetch_related( for i in self.event.items.select_related('category').prefetch_related(
'addons', 'bundles', 'addons__addon_category', 'quotas' 'addons', 'bundles', 'addons__addon_category', 'quotas'
).annotate(
has_variations=Count('variations'),
).filter( ).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache] id__in=[i for i in item_ids if i and i not in self._items_cache]
) )
@@ -214,7 +229,7 @@ class CartManager:
if op.item.require_voucher and op.voucher is None: if op.item.require_voucher and op.voucher is None:
raise CartError(error_messages['voucher_required']) raise CartError(error_messages['voucher_required'])
if op.item.hide_without_voucher and (op.voucher is None or op.voucher.item is None or op.voucher.item.pk != op.item.pk): if op.item.hide_without_voucher and (op.voucher is None or not op.voucher.show_hidden_items):
raise CartError(error_messages['voucher_required']) raise CartError(error_messages['voucher_required'])
if not op.item.is_available() or (op.variation and not op.variation.active): if not op.item.is_available() or (op.variation and not op.variation.active):
@@ -223,6 +238,12 @@ class CartManager:
if self._sales_channel not in op.item.sales_channels: if self._sales_channel not in op.item.sales_channels:
raise CartError(error_messages['unavailable']) raise CartError(error_messages['unavailable'])
if op.item.has_variations and not op.variation:
raise CartError(error_messages['not_for_sale'])
if op.variation and op.variation.item_id != op.item.pk:
raise CartError(error_messages['not_for_sale'])
if op.voucher and not op.voucher.applies_to(op.item, op.variation): if op.voucher and not op.voucher.applies_to(op.item, op.variation):
raise CartError(error_messages['voucher_invalid_item']) raise CartError(error_messages['voucher_invalid_item'])
@@ -238,6 +259,16 @@ class CartManager:
if op.subevent and op.subevent.presale_has_ended: if op.subevent and op.subevent.presale_has_ended:
raise CartError(error_messages['ended']) raise CartError(error_messages['ended'])
seated = self._is_seated(op.item, op.subevent)
if seated and (not op.seat or op.seat.blocked):
raise CartError(error_messages['seat_invalid'])
elif op.seat and not seated:
raise CartError(error_messages['seat_forbidden'])
elif op.seat and op.seat.product != op.item:
raise CartError(error_messages['seat_invalid'])
elif op.seat and op.count > 1:
raise CartError('Invalid request: A seat can only be bought once.')
if op.subevent: if op.subevent:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper) tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv: if tlv:
@@ -300,6 +331,13 @@ class CartManager:
def extend_expired_positions(self): def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related( expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'item', 'variation', 'voucher', 'addon_to', 'addon_to__item' 'item', 'variation', 'voucher', 'addon_to', 'addon_to__item'
).annotate(
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if self.event.has_subevents else Q(subevent__isnull=True))
)
)
).prefetch_related( ).prefetch_related(
'item__quotas', 'item__quotas',
'variation__quotas', 'variation__quotas',
@@ -312,6 +350,8 @@ class CartManager:
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions): if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
continue continue
cp.item.requires_seat = cp.requires_seat
if cp.is_bundled: if cp.is_bundled:
try: try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation) bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
@@ -358,7 +398,7 @@ class CartManager:
op = self.ExtendOperation( op = self.ExtendOperation(
position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1, position=cp, item=cp.item, variation=cp.variation, voucher=cp.voucher, count=1,
price=price, quotas=quotas, subevent=cp.subevent price=price, quotas=quotas, subevent=cp.subevent, seat=cp.seat
) )
self._check_item_constraints(op) self._check_item_constraints(op)
@@ -377,12 +417,6 @@ class CartManager:
operations = [] operations = []
for i in items: for i in items:
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
if self.event.has_subevents: if self.event.has_subevents:
if not i.get('subevent'): if not i.get('subevent'):
raise CartError(error_messages['subevent_required']) raise CartError(error_messages['subevent_required'])
@@ -390,6 +424,24 @@ class CartManager:
else: else:
subevent = None subevent = None
# When a seat is given, we ignore the item that was given, since we can infer it from the
# seat. The variation is still relevant, though!
seat = None
if i.get('seat'):
try:
seat = (subevent or self.event).seats.get(seat_guid=i.get('seat'))
except Seat.DoesNotExist:
raise CartError(error_messages['seat_invalid'])
i['item'] = seat.product_id
if i['item'] not in self._items_cache:
self._update_items_cache([i['item']], [i['variation']])
# Check whether the specified items are part of what we just fetched from the database
# If they are not, the user supplied item IDs which either do not exist or belong to
# a different event
if i['item'] not in self._items_cache or (i['variation'] and i['variation'] not in self._variations_cache):
raise CartError(error_messages['not_for_sale'])
item = self._items_cache[i['item']] item = self._items_cache[i['item']]
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
voucher = None voucher = None
@@ -445,7 +497,7 @@ class CartManager:
bop = self.AddOperation( bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice, count=bundle.count, item=bitem, variation=bvar, price=bprice,
voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent, voucher=None, quotas=bundle_quotas, addon_to='FAKE', subevent=subevent,
includes_tax=bool(bprice.rate), bundled=[] includes_tax=bool(bprice.rate), bundled=[], seat=None
) )
self._check_item_constraints(bop) self._check_item_constraints(bop)
bundled.append(bop) bundled.append(bop)
@@ -454,7 +506,7 @@ class CartManager:
op = self.AddOperation( op = self.AddOperation(
count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas, count=i['count'], item=item, variation=variation, price=price, voucher=voucher, quotas=quotas,
addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled addon_to=False, subevent=subevent, includes_tax=bool(price.rate), bundled=bundled, seat=seat
) )
self._check_item_constraints(op) self._check_item_constraints(op)
operations.append(op) operations.append(op)
@@ -560,7 +612,7 @@ class CartManager:
op = self.AddOperation( op = self.AddOperation(
count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas, count=1, item=item, variation=variation, price=price, voucher=None, quotas=quotas,
addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[] addon_to=cp, subevent=cp.subevent, includes_tax=bool(price.rate), bundled=[], seat=cp.seat
) )
self._check_item_constraints(op) self._check_item_constraints(op)
operations.append(op) operations.append(op)
@@ -592,6 +644,15 @@ class CartManager:
'cat': str(iao.addon_category.name), 'cat': str(iao.addon_category.name),
} }
) )
validate_cart_addons.send(
sender=self.event,
addons={
(self._items_cache[s[0]], self._variations_cache[s[1]] if s[1] else None)
for s in selected
},
base_position=cp,
iao=iao
)
# Detect removed add-ons and create RemoveOperations # Detect removed add-ons and create RemoveOperations
for cp, al in current_addons.items(): for cp, al in current_addons.items():
@@ -686,6 +747,7 @@ class CartManager:
err = err or self._check_min_per_product() err = err or self._check_min_per_product()
self._operations.sort(key=lambda a: self.order[type(a)]) self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set()
for op in self._operations: for op in self._operations:
if isinstance(op, self.RemoveOperation): if isinstance(op, self.RemoveOperation):
@@ -699,6 +761,11 @@ class CartManager:
# Create a CartPosition for as much items as we can # Create a CartPosition for as much items as we can
requested_count = quota_available_count = voucher_available_count = op.count requested_count = quota_available_count = voucher_available_count = op.count
if op.seat:
if op.seat in seats_seen:
err = err or error_messages['seat_multiple']
seats_seen.add(op.seat)
if op.quotas: if op.quotas:
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas)) quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
@@ -722,14 +789,19 @@ class CartManager:
if isinstance(op, self.AddOperation): if isinstance(op, self.AddOperation):
for b in op.bundled: for b in op.bundled:
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b.quotas)) b_quotas = list(b.quotas)
if not b_quotas:
err = err or error_messages['unavailable']
available_count = 0
continue
b_quota_available_count = min(available_count * b.count, min(quotas_ok[q] for q in b_quotas))
if b_quota_available_count < b.count: if b_quota_available_count < b.count:
err = err or error_messages['unavailable'] err = err or error_messages['unavailable']
available_count = 0 available_count = 0
elif b_quota_available_count < available_count * b.count: elif b_quota_available_count < available_count * b.count:
err = err or error_messages['in_part'] err = err or error_messages['in_part']
available_count = b_quota_available_count // b.count available_count = b_quota_available_count // b.count
for q in b.quotas: for q in b_quotas:
quotas_ok[q] -= available_count * b.count quotas_ok[q] -= available_count * b.count
# TODO: is this correct? # TODO: is this correct?
@@ -744,12 +816,16 @@ class CartManager:
available_count = 0 available_count = 0
if isinstance(op, self.AddOperation): if isinstance(op, self.AddOperation):
if op.seat and not op.seat.is_available():
available_count = 0
err = err or error_messages['seat_unavailable']
for k in range(available_count): for k in range(available_count):
cp = CartPosition( cp = CartPosition(
event=self.event, item=op.item, variation=op.variation, event=self.event, item=op.item, variation=op.variation,
price=op.price.gross, expires=self._expiry, cart_id=self.cart_id, price=op.price.gross, expires=self._expiry, cart_id=self.cart_id,
voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None, voucher=op.voucher, addon_to=op.addon_to if op.addon_to else None,
subevent=op.subevent, includes_tax=op.includes_tax subevent=op.subevent, includes_tax=op.includes_tax, seat=op.seat
) )
if self.event.settings.attendee_names_asked: if self.event.settings.attendee_names_asked:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme) scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -788,7 +864,11 @@ class CartManager:
new_cart_positions.append(cp) new_cart_positions.append(cp)
elif isinstance(op, self.ExtendOperation): elif isinstance(op, self.ExtendOperation):
if available_count == 1: if op.seat and not op.seat.is_available(ignore_cart=op.position):
err = err or error_messages['seat_unavailable']
op.position.addons.all().delete()
op.position.delete()
elif available_count == 1:
op.position.expires = self._expiry op.position.expires = self._expiry
op.position.price = op.price.gross op.position.price = op.price.gross
try: try:
@@ -819,6 +899,9 @@ class CartManager:
# If any quotas are affected that are not unlimited, we lock # If any quotas are affected that are not unlimited, we lock
return True return True
if any(getattr(o, 'seat', False) for o in self._operations):
return True
return False return False
def commit(self): def commit(self):
@@ -870,6 +953,12 @@ def update_tax_rates(event: Event, cart_id: str, invoice_address: InvoiceAddress
def get_fees(event, request, total, invoice_address, provider): def get_fees(event, request, total, invoice_address, provider):
fees = [] fees = []
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total):
if resp:
fees += resp
total = total + sum(f.value for f in fees)
if provider and total != 0: if provider and total != 0:
provider = event.get_payment_providers().get(provider) provider = event.get_payment_providers().get(provider)
if provider: if provider:
@@ -895,30 +984,25 @@ def get_fees(event, request, total, invoice_address, provider):
tax_rule=payment_fee_tax_rule tax_rule=payment_fee_tax_rule
)) ))
for recv, resp in fee_calculation_for_cart.send(sender=event, request=request, invoice_address=invoice_address,
total=total):
fees += resp
return fees return fees
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en', def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, widget_data=None, sales_channel='web') -> None: invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
""" """
Adds a list of items to a user's cart. Adds a list of items to a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param items: A list of dicts with the keys item, variation, count, custom_price, voucher :param items: A list of dicts with the keys item, variation, count, custom_price, voucher, seat ID
:param cart_id: Session ID of a guest :param cart_id: Session ID of a guest
:raises CartError: On any error that occured :raises CartError: On any error that occured
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
ia = False ia = False
if invoice_address: if invoice_address:
try: try:
ia = InvoiceAddress.objects.get(pk=invoice_address) with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
@@ -934,8 +1018,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def remove_cart_position(self, event: int, position: int, cart_id: str=None, locale='en') -> None: def remove_cart_position(self, event: Event, position: int, cart_id: str=None, locale='en') -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
@@ -943,7 +1027,6 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id) cm = CartManager(event=event, cart_id=cart_id)
@@ -955,15 +1038,14 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None: def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
:param event: The event ID in question :param event: The event ID in question
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
try: try:
try: try:
cm = CartManager(event=event, cart_id=cart_id) cm = CartManager(event=event, cart_id=cart_id)
@@ -975,8 +1057,8 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
raise CartError(error_messages['busy']) raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, locale='en', def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web') -> None: invoice_address: int=None, sales_channel='web') -> None:
""" """
Removes a list of items from a user's cart. Removes a list of items from a user's cart.
@@ -985,12 +1067,11 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
:param session: Session ID of a guest :param session: Session ID of a guest
""" """
with language(locale): with language(locale):
event = Event.objects.get(id=event)
ia = False ia = False
if invoice_address: if invoice_address:
try: try:
ia = InvoiceAddress.objects.get(pk=invoice_address) with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
try: try:

View File

@@ -60,7 +60,7 @@ def _save_answers(op, answers, given_answers):
@transaction.atomic @transaction.atomic
def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False, def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict, force=False,
ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True, ignore_unpaid=False, nonce=None, datetime=None, questions_supported=True,
user=None, auth=None): user=None, auth=None, canceled_supported=False):
""" """
Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is Create a checkin for this particular order position and check-in list. Fails with CheckInError if the check in is
not valid at this time. not valid at this time.
@@ -90,10 +90,10 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
'answers' 'answers'
).get(pk=op.pk) ).get(pk=op.pk)
if op.canceled: if op.canceled or op.order.status not in (Order.STATUS_PAID, Order.STATUS_PENDING):
raise CheckInError( raise CheckInError(
_('This order position has been canceled.'), _('This order position has been canceled.'),
'unpaid' 'canceled' if canceled_supported else 'unpaid'
) )
answers = {a.question: a for a in op.answers.all()} answers = {a.question: a for a in op.answers.all()}

View File

@@ -2,6 +2,7 @@ from datetime import timedelta
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import CachedCombinedTicket, CachedTicket from pretix.base.models import CachedCombinedTicket, CachedTicket
@@ -10,6 +11,7 @@ from ..signals import periodic_task
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def clean_cart_positions(sender, **kwargs): def clean_cart_positions(sender, **kwargs):
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False): for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False):
cp.delete() cp.delete()
@@ -20,12 +22,14 @@ def clean_cart_positions(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_files(sender, **kwargs): def clean_cached_files(sender, **kwargs):
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()): for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
cf.delete() cf.delete()
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_tickets(sender, **kwargs): def clean_cached_tickets(sender, **kwargs):
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)): for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
cf.delete() cf.delete()

View File

@@ -6,7 +6,7 @@ from django.utils.translation import ugettext
from pretix.base.i18n import LazyLocaleException, language from pretix.base.i18n import LazyLocaleException, language
from pretix.base.models import CachedFile, Event, cachedfile_name from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.tasks import ProfiledTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.signals import register_data_exporters from pretix.base.signals import register_data_exporters
from pretix.celery_app import app from pretix.celery_app import app
@@ -15,9 +15,8 @@ class ExportError(LazyLocaleException):
pass pass
@app.task(base=ProfiledTask, throws=(ExportError,)) @app.task(base=ProfiledEventTask, throws=(ExportError,))
def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
event = Event.objects.get(id=event)
file = CachedFile.objects.get(id=fileid) file = CachedFile.objects.get(id=fileid)
with language(event.settings.locale), override(event.settings.timezone): with language(event.settings.locale), override(event.settings.timezone):
responses = register_data_exporters.send(event) responses = register_data_exporters.send(event)

View File

@@ -1,3 +1,4 @@
import inspect
import json import json
import logging import logging
import urllib.error import urllib.error
@@ -15,6 +16,7 @@ from django.utils import timezone
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _ from django.utils.translation import pgettext, ugettext as _
from django_countries.fields import Country from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language from pretix.base.i18n import language
@@ -52,7 +54,10 @@ def build_invoice(invoice: Invoice) -> Invoice:
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString) additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString) footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if open_payment and open_payment.payment_provider: if open_payment and open_payment.payment_provider:
payment = open_payment.payment_provider.render_invoice_text(invoice.order) if 'payment' in inspect.signature(open_payment.payment_provider.render_invoice_text).parameters:
payment = open_payment.payment_provider.render_invoice_text(invoice.order, open_payment)
else:
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
elif invoice.order.status == Order.STATUS_PAID: elif invoice.order.status == Order.STATUS_PAID:
payment = pgettext('invoice', 'The payment for this invoice has already been received.') payment = pgettext('invoice', 'The payment for this invoice has already been received.')
else: else:
@@ -244,16 +249,18 @@ def generate_invoice(order: Order, trigger_pdf=True):
@app.task(base=TransactionAwareTask) @app.task(base=TransactionAwareTask)
def invoice_pdf_task(invoice: int): def invoice_pdf_task(invoice: int):
i = Invoice.objects.get(pk=invoice) with scopes_disabled():
if i.shredded: i = Invoice.objects.get(pk=invoice)
return None with scope(organizer=i.order.event.organizer):
if i.file: if i.shredded:
i.file.delete() return None
with language(i.locale): if i.file:
fname, ftype, fcontent = i.event.invoice_renderer.generate(i) i.file.delete()
i.file.save(fname, ContentFile(fcontent)) with language(i.locale):
i.save() fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
return i.file.name i.file.save(fname, ContentFile(fcontent))
i.save()
return i.file.name
def invoice_qualified(order: Order): def invoice_qualified(order: Order):

View File

@@ -1,15 +1,24 @@
import inspect
import logging import logging
import os
import re
import smtplib import smtplib
import warnings import warnings
from email.encoders import encode_noop
from email.mime.image import MIMEImage
from email.utils import formataddr from email.utils import formataddr
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
from urllib.parse import urljoin, urlparse
import cssutils import cssutils
import requests
from bs4 import BeautifulSoup
from celery import chain from celery import chain
from django.conf import settings from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix.base.email import ClassicMailRenderer from pretix.base.email import ClassicMailRenderer
@@ -176,9 +185,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
body_plain += "\r\n" body_plain += "\r\n"
try: try:
try: if 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, str(subject), order, position) body_html = renderer.render(content_plain, signature, str(subject), order, position)
except TypeError: else:
# Backwards compatibility # Backwards compatibility
warnings.warn('E-mail renderer called without position argument because position argument is not ' warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.', 'supported.',
@@ -218,99 +227,105 @@ def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: st
invoices: List[int]=None, order: int=None, attach_tickets=False) -> bool: invoices: List[int]=None, order: int=None, attach_tickets=False) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers) email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None: if html is not None:
email.attach_alternative(html, "text/html") html_with_cid, cid_images = replace_images_with_cid_paths(html)
if invoices: email = attach_cid_images(email, cid_images, verify_ssl=True)
invoices = Invoice.objects.filter(pk__in=invoices) email.attach_alternative(html_with_cid, "text/html")
for inv in invoices:
if inv.file:
try:
email.attach(
'{}.pdf'.format(inv.number),
inv.file.file.read(),
'application/pdf'
)
except:
logger.exception('Could not attach invoice to email')
pass
if event: if event:
event = Event.objects.get(id=event) with scopes_disabled():
event = Event.objects.get(id=event)
backend = event.get_mail_backend() backend = event.get_mail_backend()
cm = lambda: scope(organizer=event.organizer) # noqa
else: else:
backend = get_connection(fail_silently=False) backend = get_connection(fail_silently=False)
cm = lambda: scopes_disabled() # noqa
if event: with cm():
if order: if invoices:
try: invoices = Invoice.objects.filter(pk__in=invoices)
order = event.orders.get(pk=order) for inv in invoices:
except Order.DoesNotExist: if inv.file:
order = None
else:
if position:
try: try:
position = order.positions.get(pk=position) email.attach(
except OrderPosition.DoesNotExist: '{}.pdf'.format(inv.number),
attach_tickets = False inv.file.file.read(),
if attach_tickets: 'application/pdf'
args = []
attach_size = 0
for name, ct in get_tickets_for_order(order, base_position=position):
content = ct.file.read()
args.append((name, content, ct.type))
attach_size += len(content)
if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
) )
except:
logger.exception('Could not attach invoice to email')
pass
if event:
if order:
try:
order = event.orders.get(pk=order)
except Order.DoesNotExist:
order = None
else:
if position:
try:
position = order.positions.get(pk=position)
except OrderPosition.DoesNotExist:
attach_tickets = False
if attach_tickets:
args = []
attach_size = 0
for name, ct in get_tickets_for_order(order, base_position=position):
content = ct.file.read()
args.append((name, content, ct.type))
attach_size += len(content)
email = email_filter.send_chained(event, 'message', message=email, order=order) if attach_size < 4 * 1024 * 1024:
# Do not attach more than 4MB, it will bounce way to often.
for a in args:
try:
email.attach(*a)
except:
pass
else:
order.log_action(
'pretix.event.order.email.attachments.skipped',
data={
'subject': 'Attachments skipped',
'message': 'Attachment have not been send because {} bytes are likely too large to arrive.'.format(attach_size),
'recipient': '',
'invoices': [],
}
)
try: email = email_filter.send_chained(event, 'message', message=email, order=order)
backend.send_messages([email])
except smtplib.SMTPResponseException as e:
if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
logger.exception('Error sending email')
if order: try:
order.log_action( backend.send_messages([email])
'pretix.event.order.email.error', except smtplib.SMTPResponseException as e:
data={ if e.smtp_code in (101, 111, 421, 422, 431, 442, 447, 452):
'subject': 'SMTP code {}'.format(e.smtp_code), self.retry(max_retries=5, countdown=2 ** (self.request.retries * 2))
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error), logger.exception('Error sending email')
'recipient': '',
'invoices': [],
}
)
raise SendMailException('Failed to send an email to {}.'.format(to)) if order:
except Exception as e: order.log_action(
if order: 'pretix.event.order.email.error',
order.log_action( data={
'pretix.event.order.email.error', 'subject': 'SMTP code {}'.format(e.smtp_code),
data={ 'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
'subject': 'Internal error', 'recipient': '',
'message': str(e), 'invoices': [],
'recipient': '', }
'invoices': [], )
}
) raise SendMailException('Failed to send an email to {}.'.format(to))
logger.exception('Error sending email') except Exception as e:
raise SendMailException('Failed to send an email to {}.'.format(to)) if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'Internal error',
'message': str(e),
'recipient': '',
'invoices': [],
}
)
logger.exception('Error sending email')
raise SendMailException('Failed to send an email to {}.'.format(to))
def mail_send(*args, **kwargs): def mail_send(*args, **kwargs):
@@ -326,3 +341,92 @@ def render_mail(template, context):
tpl = get_template(template) tpl = get_template(template)
body = tpl.render(context) body = tpl.render(context)
return body return body
def replace_images_with_cid_paths(body_html):
if body_html:
email = BeautifulSoup(body_html, "lxml")
cid_images = []
for image in email.findAll('img'):
original_image_src = image['src']
try:
cid_id = "image_%s" % cid_images.index(original_image_src)
except ValueError:
cid_images.append(original_image_src)
cid_id = "image_%s" % (len(cid_images) - 1)
image['src'] = "cid:%s" % cid_id
return email.prettify(), cid_images
else:
return body_html, []
def attach_cid_images(msg, cid_images, verify_ssl=True):
if cid_images and len(cid_images) > 0:
msg.mixed_subtype = 'related'
for key, image in enumerate(cid_images):
cid = 'image_%s' % key
try:
mime_image = convert_image_to_cid(
image, cid, verify_ssl)
if mime_image:
msg.attach(mime_image)
except:
logger.exception("ERROR attaching CID image %s[%s]" % (cid, image))
return msg
def convert_image_to_cid(image_src, cid_id, verify_ssl=True):
try:
if image_src.startswith('data:image/'):
image_type, image_content = image_src.split(',', 1)
image_type = re.findall(r'data:image/(\w+);base64', image_type)[0]
mime_image = MIMEImage(image_content, _subtype=image_type, _encoder=encode_noop)
mime_image.add_header('Content-Transfer-Encoding', 'base64')
elif image_src.startswith('data:'):
logger.exception("ERROR creating MIME element %s[%s]" % (cid_id, image_src))
return None
else:
image_src = normalize_image_url(image_src)
path = urlparse(image_src).path
guess_subtype = os.path.splitext(path)[1][1:]
response = requests.get(image_src, verify=verify_ssl)
mime_image = MIMEImage(
response.content, _subtype=guess_subtype)
mime_image.add_header('Content-ID', '<%s>' % cid_id)
return mime_image
except:
logger.exception("ERROR creating mime_image %s[%s]" % (cid_id, image_src))
return None
def normalize_image_url(url):
if '://' not in url:
"""
If we see a relative URL in an email, we can't know if it is meant to be a media file
or a static file, so we need to guess. If it is a static file included with the
``{% static %}`` template tag (as it should be), then ``STATIC_URL`` is already prepended.
If ``STATIC_URL`` is absolute, then ``url`` should already be absolute and this
function should not be triggered. Thus, if we see a relative URL and ``STATIC_URL``
is absolute *or* ``url`` does not start with ``STATIC_URL``, we can be sure this
is a media file (or a programmer error …).
Constructing the URL of either a static file or a media file from settings is still
not clean, since custom storage backends might very well use more complex approaches
to build those URLs. However, this is good enough as a best-effort approach. Complex
storage backends (such as cloud storages) will return absolute URLs anyways so this
function is not needed in that case.
"""
if '://' not in settings.STATIC_URL and url.startswith(settings.STATIC_URL):
url = urljoin(settings.SITE_URL, url)
else:
url = urljoin(settings.MEDIA_URL, url)
return url

View File

@@ -1,5 +1,6 @@
from django.conf import settings from django.conf import settings
from django.template.loader import get_template from django.template.loader import get_template
from django_scopes import scope, scopes_disabled
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
from pretix.base.i18n import language from pretix.base.i18n import language
@@ -12,6 +13,7 @@ from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask) @app.task(base=TransactionAwareTask)
@scopes_disabled()
def notify(logentry_id: int): def notify(logentry_id: int):
logentry = LogEntry.all.get(id=logentry_id) logentry = LogEntry.all.get(id=logentry_id)
if not logentry.event: if not logentry.event:
@@ -30,7 +32,7 @@ def notify(logentry_id: int):
# All users that have the permission to get the notification # All users that have the permission to get the notification
users = logentry.event.get_users_with_permission( users = logentry.event.get_users_with_permission(
notification_type.required_permission notification_type.required_permission
).filter(notifications_send=True) ).filter(notifications_send=True, is_active=True)
if logentry.user: if logentry.user:
users = users.exclude(pk=logentry.user.pk) users = users.exclude(pk=logentry.user.pk)
@@ -66,17 +68,22 @@ def notify(logentry_id: int):
@app.task(base=ProfiledTask) @app.task(base=ProfiledTask)
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str): def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id) logentry = LogEntry.all.get(id=logentry_id)
user = User.objects.get(id=user_id) if logentry.event:
types = get_all_notification_types(logentry.event) sm = lambda: scope(organizer=logentry.event.organizer) # noqa
notification_type = types.get(action_type) else:
if not notification_type: sm = lambda: scopes_disabled() # noqa
return # Ignore, e.g. plugin not active for this event with sm():
user = User.objects.get(id=user_id)
types = get_all_notification_types(logentry.event)
notification_type = types.get(action_type)
if not notification_type:
return # Ignore, e.g. plugin not active for this event
with language(user.locale): with language(user.locale):
notification = notification_type.build_notification(logentry) notification = notification_type.build_notification(logentry)
if method == "mail": if method == "mail":
send_notification_mail(notification, user) send_notification_mail(notification, user)
def send_notification_mail(notification: Notification, user: User): def send_notification_mail(notification: Notification, user: User):

View File

@@ -1,3 +1,4 @@
import inspect
import json import json
import logging import logging
from collections import Counter, namedtuple from collections import Counter, namedtuple
@@ -16,6 +17,7 @@ from django.utils.formats import date_format
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication from pretix.api.models import OAuthApplication
from pretix.base.i18n import ( from pretix.base.i18n import (
@@ -23,7 +25,7 @@ from pretix.base.i18n import (
) )
from pretix.base.models import ( from pretix.base.models import (
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment, CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
OrderPosition, Quota, User, Voucher, OrderPosition, Quota, Seat, SeatCategoryMapping, User, Voucher,
) )
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.items import ItemBundle from pretix.base.models.items import ItemBundle
@@ -42,12 +44,12 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import LockTimeoutException, NoLockManager from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask from pretix.base.services.tasks import ProfiledEventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import ( from pretix.base.signals import (
allow_ticket_download, order_approved, order_canceled, order_changed, allow_ticket_download, order_approved, order_canceled, order_changed,
order_denied, order_expired, order_fee_calculation, order_placed, order_denied, order_expired, order_fee_calculation, order_placed,
periodic_task, validate_order, order_split, periodic_task, validate_order,
) )
from pretix.celery_app import app from pretix.celery_app import app
from pretix.helpers.models import modelcopy from pretix.helpers.models import modelcopy
@@ -81,6 +83,8 @@ error_messages = {
'affected positions have been removed from your cart.'), 'affected positions have been removed from your cart.'),
'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected ' 'some_subevent_ended': _('The presale period for one of the events in your cart has ended. The affected '
'positions have been removed from your cart.'), 'positions have been removed from your cart.'),
'seat_invalid': _('One of the seats in your order was invalid, we removed the position from your cart.'),
'seat_unavailable': _('One of the seats in your order has been taken in the meantime, we removed the position from your cart.'),
} }
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
@@ -427,6 +431,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter() products_seen = Counter()
changed_prices = {} changed_prices = {}
deleted_positions = set() deleted_positions = set()
seats_seen = set()
def delete(cp): def delete(cp):
# Delete a cart position, including parents and children, if applicable # Delete a cart position, including parents and children, if applicable
@@ -489,17 +494,32 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp) delete(cp)
break break
if (cp.requires_seat and not cp.seat) or (cp.seat and not cp.requires_seat) or (cp.seat and cp.seat.product != cp.item) or cp.seat in seats_seen:
err = err or error_messages['seat_invalid']
delete(cp)
break
if cp.seat:
seats_seen.add(cp.seat)
if cp.item.require_voucher and cp.voucher is None: if cp.item.require_voucher and cp.voucher is None:
delete(cp) delete(cp)
err = err or error_messages['voucher_required'] err = err or error_messages['voucher_required']
break break
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None if cp.item.hide_without_voucher and (cp.voucher is None or not cp.voucher.show_hidden_items or not cp.voucher.applies_to(cp.item, cp.variation)):
or cp.voucher.item.pk != cp.item.pk):
delete(cp) delete(cp)
cp.delete()
err = error_messages['voucher_required'] err = error_messages['voucher_required']
break break
if cp.seat:
# Unlike quotas (which we blindly trust as long as the position is not expired), we check seats every time, since we absolutely
# can not overbook a seat.
if not cp.seat.is_available(ignore_cart=cp) or cp.seat.blocked:
err = err or error_messages['seat_unavailable']
cp.delete()
continue
if cp.expires >= now_dt and not cp.voucher: if cp.expires >= now_dt and not cp.voucher:
# Other checks are not necessary # Other checks are not necessary
continue continue
@@ -572,6 +592,13 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
meta_info: dict, event: Event): meta_info: dict, event: Event):
fees = [] fees = []
total = sum([c.price for c in positions]) total = sum([c.price for c in positions])
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions):
if resp:
fees += resp
total += sum(f.value for f in fees)
if payment_provider: if payment_provider:
payment_fee = payment_provider.calculate_fee(total) payment_fee = payment_provider.calculate_fee(total)
else: else:
@@ -582,9 +609,6 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
internal_type=payment_provider.identifier) internal_type=payment_provider.identifier)
fees.append(pf) fees.append(pf)
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions):
fees += resp
return fees, pf return fees, pf
@@ -647,7 +671,7 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str, def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
invoice): invoice, payment: OrderPayment):
try: try:
invoice_name = order.invoice_address.name invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company invoice_company = order.invoice_address.company
@@ -656,7 +680,10 @@ def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider,
invoice_company = "" invoice_company = ""
if pprov: if pprov:
payment_info = str(pprov.order_pending_mail_render(order)) if 'payment' in inspect.signature(pprov.order_pending_mail_render).parameters:
payment_info = str(pprov.order_pending_mail_render(order, payment))
else:
payment_info = str(pprov.order_pending_mail_render(order))
else: else:
payment_info = None payment_info = None
@@ -715,10 +742,8 @@ def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosi
logger.exception('Order received email could not be sent to attendee') logger.exception('Order received email could not be sent to attendee')
def _perform_order(event: str, payment_provider: str, position_ids: List[str], def _perform_order(event: Event, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'): email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
event = Event.objects.get(id=event)
if payment_provider: if payment_provider:
pprov = event.get_payment_providers().get(payment_provider) pprov = event.get_payment_providers().get(payment_provider)
if not pprov: if not pprov:
@@ -732,25 +757,35 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
addr = None addr = None
if address is not None: if address is not None:
try: try:
addr = InvoiceAddress.objects.get(pk=address) with scopes_disabled():
addr = InvoiceAddress.objects.get(pk=address)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
pass pass
positions = CartPosition.objects.filter(id__in=position_ids, event=event) positions = CartPosition.objects.annotate(
requires_seat=Exists(
SeatCategoryMapping.objects.filter(
Q(product=OuterRef('item'))
& (Q(subevent=OuterRef('subevent')) if event.has_subevents else Q(subevent__isnull=True))
)
)
).filter(
id__in=position_ids, event=event
)
validate_order.send(event, payment_provider=pprov, email=email, positions=positions, validate_order.send(event, payment_provider=pprov, email=email, positions=positions,
locale=locale, invoice_address=addr, meta_info=meta_info) locale=locale, invoice_address=addr, meta_info=meta_info)
lockfn = NoLockManager lockfn = NoLockManager
locked = False locked = False
if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2))).exists(): if positions.filter(Q(voucher__isnull=False) | Q(expires__lt=now() + timedelta(minutes=2)) | Q(seat__isnull=False)).exists():
# Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date, # Performance optimization: If no voucher is used and no cart position is dangerously close to its expiry date,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event. # creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True locked = True
lockfn = event.lock lockfn = event.lock
with lockfn() as now_dt: with lockfn() as now_dt:
positions = list(positions.select_related('item', 'variation', 'subevent', 'addon_to').prefetch_related('addons')) positions = list(positions.select_related('item', 'variation', 'subevent', 'seat', 'addon_to').prefetch_related('addons'))
if len(positions) == 0: if len(positions) == 0:
raise OrderError(error_messages['empty']) raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions): if len(position_ids) != len(positions):
@@ -794,7 +829,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
email_attendees = event.settings.mail_send_order_placed_attendee email_attendees = event.settings.mail_send_order_placed_attendee
email_attendees_template = event.settings.mail_text_order_placed_attendee email_attendees_template = event.settings.mail_text_order_placed_attendee
_order_placed_email(event, order, pprov, email_template, log_entry, invoice) _order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment)
if email_attendees: if email_attendees:
for p in order.positions.all(): for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email: if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
@@ -804,6 +839,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def expire_orders(sender, **kwargs): def expire_orders(sender, **kwargs):
eventcache = {} eventcache = {}
@@ -818,6 +854,7 @@ def expire_orders(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def send_expiry_warnings(sender, **kwargs): def send_expiry_warnings(sender, **kwargs):
eventcache = {} eventcache = {}
today = now().replace(hour=0, minute=0, second=0) today = now().replace(hour=0, minute=0, second=0)
@@ -875,6 +912,7 @@ def send_expiry_warnings(sender, **kwargs):
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def send_download_reminders(sender, **kwargs): def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0) today = now().replace(hour=0, minute=0, second=0, microsecond=0)
@@ -945,6 +983,35 @@ def send_download_reminders(sender, **kwargs):
logger.exception('Reminder email could not be sent to attendee') logger.exception('Reminder email could not be sent to attendee')
def notify_user_changed_order(order, user=None, auth=None):
with language(order.locale):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = order.event.settings.mail_text_order_changed
email_context = {
'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', user, auth=auth
)
except SendMailException:
logger.exception('Order changed email could not be sent')
class OrderChangeManager: class OrderChangeManager:
error_messages = { error_messages = {
'product_without_variation': _('You need to select a variation of the product.'), 'product_without_variation': _('You need to select a variation of the product.'),
@@ -958,12 +1025,17 @@ class OrderChangeManager:
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'), 'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'), 'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'), 'subevent_required': _('You need to choose a subevent for the new position.'),
'seat_unavailable': _('The selected seat "{seat}" is not available.'),
'seat_subevent_mismatch': _('You selected seat "{seat}" for a date that does not match the selected ticket date. Please choose a seat again.'),
'seat_required': _('The selected product requires you to select a seat.'),
'seat_forbidden': _('The selected product does not allow to select a seat.'),
} }
ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation')) ItemOperation = namedtuple('ItemOperation', ('position', 'item', 'variation'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent')) SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price')) PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
CancelOperation = namedtuple('CancelOperation', ('position',)) CancelOperation = namedtuple('CancelOperation', ('position',))
AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent')) AddOperation = namedtuple('AddOperation', ('item', 'variation', 'price', 'addon_to', 'subevent', 'seat'))
SplitOperation = namedtuple('SplitOperation', ('position',)) SplitOperation = namedtuple('SplitOperation', ('position',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',)) RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
@@ -976,6 +1048,7 @@ class OrderChangeManager:
self._committed = False self._committed = False
self._totaldiff = 0 self._totaldiff = 0
self._quotadiff = Counter() self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = [] self._operations = []
self.notify = notify self.notify = notify
self._invoice_dirty = False self._invoice_dirty = False
@@ -993,6 +1066,13 @@ class OrderChangeManager:
self._quotadiff.subtract(position.quotas) self._quotadiff.subtract(position.quotas)
self._operations.append(self.ItemOperation(position, item, variation)) self._operations.append(self.ItemOperation(position, item, variation))
def change_seat(self, position: OrderPosition, seat: Seat):
if position.seat:
self._seatdiff.subtract([position.seat])
if seat:
self._seatdiff.update([seat])
self._operations.append(self.SeatOperation(position, seat))
def change_subevent(self, position: OrderPosition, subevent: SubEvent): def change_subevent(self, position: OrderPosition, subevent: SubEvent):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent, price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address) invoice_address=self._invoice_address)
@@ -1048,12 +1128,14 @@ class OrderChangeManager:
self._totaldiff += -position.price self._totaldiff += -position.price
self._quotadiff.subtract(position.quotas) self._quotadiff.subtract(position.quotas)
self._operations.append(self.CancelOperation(position)) self._operations.append(self.CancelOperation(position))
if position.seat:
self._seatdiff.subtract([position.seat])
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
self._invoice_dirty = True self._invoice_dirty = True
def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None, def add_position(self, item: Item, variation: ItemVariation, price: Decimal, addon_to: Order = None,
subevent: SubEvent = None): subevent: SubEvent = None, seat: Seat = None):
if price is None: if price is None:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address) price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else: else:
@@ -1072,6 +1154,14 @@ class OrderChangeManager:
if self.order.event.has_subevents and not subevent: if self.order.event.has_subevents and not subevent:
raise OrderError(self.error_messages['subevent_required']) raise OrderError(self.error_messages['subevent_required'])
seated = item.seat_category_mappings.filter(subevent=subevent).exists()
if seated and not seat:
raise OrderError(self.error_messages['seat_required'])
elif not seated and seat:
raise OrderError(self.error_messages['seat_forbidden'])
if seat and subevent and seat.subevent_id != subevent:
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=seat.name))
new_quotas = (variation.quotas.filter(subevent=subevent) new_quotas = (variation.quotas.filter(subevent=subevent)
if variation else item.quotas.filter(subevent=subevent)) if variation else item.quotas.filter(subevent=subevent))
if not new_quotas: if not new_quotas:
@@ -1082,7 +1172,9 @@ class OrderChangeManager:
self._totaldiff += price.gross self._totaldiff += price.gross
self._quotadiff.update(new_quotas) self._quotadiff.update(new_quotas)
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent)) if seat:
self._seatdiff.update([seat])
self._operations.append(self.AddOperation(item, variation, price, addon_to, subevent, seat))
def split(self, position: OrderPosition): def split(self, position: OrderPosition):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'): if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1090,6 +1182,26 @@ class OrderChangeManager:
self._operations.append(self.SplitOperation(position)) self._operations.append(self.SplitOperation(position))
def _check_seats(self):
for seat, diff in self._seatdiff.items():
if diff <= 0:
continue
if not seat.is_available() or diff > 1:
raise OrderError(self.error_messages['seat_unavailable'].format(seat=seat.name))
if self.event.has_subevents:
state = {}
for p in self.order.positions.all():
state[p] = {'seat': p.seat, 'subevent': p.subevent}
for op in self._operations:
if isinstance(op, self.SeatOperation):
state[op.position]['seat'] = op.seat
elif isinstance(op, self.SubeventOperation):
state[op.position]['subevent'] = op.subevent
for v in state.values():
if v['seat'] and v['seat'].subevent_id != v['subevent'].pk:
raise OrderError(self.error_messages['seat_subevent_mismatch'].format(seat=v['seat'].name))
def _check_quotas(self): def _check_quotas(self):
for quota, diff in self._quotadiff.items(): for quota, diff in self._quotadiff.items():
if diff <= 0: if diff <= 0:
@@ -1156,7 +1268,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['paid_to_free_exceeded']) raise OrderError(self.error_messages['paid_to_free_exceeded'])
def _perform_operations(self): def _perform_operations(self):
nextposid = self.order.positions.aggregate(m=Max('positionid'))['m'] + 1 nextposid = self.order.all_positions.aggregate(m=Max('positionid'))['m'] + 1
split_positions = [] split_positions = []
for op in self._operations: for op in self._operations:
@@ -1176,6 +1288,17 @@ class OrderChangeManager:
op.position.variation = op.variation op.position.variation = op.variation
op.position._calculate_tax() op.position._calculate_tax()
op.position.save() op.position.save()
elif isinstance(op, self.SeatOperation):
self.order.log_action('pretix.event.order.changed.seat', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_seat': op.position.seat.name if op.position.seat else "-",
'new_seat': op.seat.name if op.seat else "-",
'old_seat_id': op.position.seat.pk if op.position.seat else None,
'new_seat_id': op.seat.pk if op.seat else None,
})
op.position.seat = op.seat
op.position.save()
elif isinstance(op, self.SubeventOperation): elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
'position': op.position.pk, 'position': op.position.pk,
@@ -1229,7 +1352,7 @@ class OrderChangeManager:
item=op.item, variation=op.variation, addon_to=op.addon_to, item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate, price=op.price.gross, order=self.order, tax_rate=op.price.rate,
tax_value=op.price.tax, tax_rule=op.item.tax_rule, tax_value=op.price.tax, tax_rule=op.item.tax_rule,
positionid=nextposid, subevent=op.subevent positionid=nextposid, subevent=op.subevent, seat=op.seat
) )
nextposid += 1 nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={ self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -1240,6 +1363,7 @@ class OrderChangeManager:
'price': op.price.gross, 'price': op.price.gross,
'positionid': pos.positionid, 'positionid': pos.positionid,
'subevent': op.subevent.pk if op.subevent else None, 'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
}) })
elif isinstance(op, self.SplitOperation): elif isinstance(op, self.SplitOperation):
split_positions.append(op.position) split_positions.append(op.position)
@@ -1290,6 +1414,14 @@ class OrderChangeManager:
pass pass
split_order.total = sum([p.price for p in split_positions if not p.canceled]) split_order.total = sum([p.price for p in split_positions if not p.canceled])
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
split_order.total += new_fee.value
new_fee.save()
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID: if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
pp = self._get_payment_provider() pp = self._get_payment_provider()
if pp: if pp:
@@ -1305,13 +1437,6 @@ class OrderChangeManager:
fee.delete() fee.delete()
split_order.total += fee.value split_order.total += fee.value
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
split_order.total += new_fee.value
new_fee.save()
split_order.save() split_order.save()
if split_order.status == Order.STATUS_PAID: if split_order.status == Order.STATUS_PAID:
@@ -1332,6 +1457,8 @@ class OrderChangeManager:
if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last(): if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last():
generate_invoice(split_order) generate_invoice(split_order)
order_split.send(sender=self.order.event, original=self.order, split_order=split_order)
return split_order return split_order
@cached_property @cached_property
@@ -1417,34 +1544,6 @@ class OrderChangeManager:
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
return None return None
def _notify_user(self, order):
with language(order.locale):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = order.event.settings.mail_text_order_changed
email_context = {
'event': order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order has been changed: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', self.user, auth=self.auth
)
except SendMailException:
logger.exception('Order changed email could not be sent')
def commit(self, check_quotas=True): def commit(self, check_quotas=True):
if self._committed: if self._committed:
# an order change can only be committed once # an order change can only be committed once
@@ -1464,6 +1563,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['not_pending_or_paid']) raise OrderError(self.error_messages['not_pending_or_paid'])
if check_quotas: if check_quotas:
self._check_quotas() self._check_quotas()
self._check_seats()
self._check_complete_cancel() self._check_complete_cancel()
self._perform_operations() self._perform_operations()
self._recalculate_total_and_payment_fee() self._recalculate_total_and_payment_fee()
@@ -1474,9 +1574,9 @@ class OrderChangeManager:
self._check_paid_to_free() self._check_paid_to_free()
if self.notify: if self.notify:
self._notify_user(self.order) notify_user_changed_order(self.order, self.user, self.auth)
if self.split_order: if self.split_order:
self._notify_user(self.split_order) notify_user_changed_order(self.split_order, self.user, self.auth)
order_changed.send(self.order.event, order=self.order) order_changed.send(self.order.event, order=self.order)
@@ -1497,8 +1597,8 @@ class OrderChangeManager:
return pprov return pprov
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: str, payment_provider: str, positions: List[str], def perform_order(self, event: Event, payment_provider: str, positions: List[str],
email: str=None, locale: str=None, address: int=None, meta_info: dict=None, email: str=None, locale: str=None, address: int=None, meta_info: dict=None,
sales_channel: str='web'): sales_channel: str='web'):
with language(locale): with language(locale):
@@ -1513,6 +1613,7 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,)) @app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
@scopes_disabled()
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None, def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None, cancellation_fee=None, try_auto_refund=False): device=None, cancellation_fee=None, try_auto_refund=False):
try: try:

View File

@@ -4,6 +4,7 @@ from django.conf import settings
from django.db.models import Max, Q from django.db.models import Max, Q
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import Event, LogEntry from pretix.base.models import Event, LogEntry
from pretix.celery_app import app from pretix.celery_app import app
@@ -17,6 +18,7 @@ def build_all_quota_caches(sender, **kwargs):
@app.task @app.task
@scopes_disabled()
def refresh_quota_caches(): def refresh_quota_caches():
# Active events # Active events
active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter( active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter(

View File

@@ -0,0 +1,73 @@
from django.db.models import Count
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CartPosition, Seat
class SeatProtected(Exception):
pass
def validate_plan_change(event, subevent, plan):
current_taken_seats = set(
event.seats.select_related('product')
.annotate(has_op=Count('orderposition'))
.filter(subevent=subevent, has_op=True)
.values_list('seat_guid', flat=True)
)
new_seats = {
ss.guid for ss in plan.iter_all_seats()
} if plan else set()
leftovers = list(current_taken_seats - new_seats)
if leftovers:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(leftovers[0]))
def generate_seats(event, subevent, plan, mapping):
current_seats = {
s.seat_guid: s for s in
event.seats.select_related('product').annotate(has_op=Count('orderposition')).filter(subevent=subevent)
}
def update(o, a, v):
if getattr(o, a) != v:
setattr(o, a, v)
return True
return False
create_seats = []
if plan:
for ss in plan.iter_all_seats():
p = mapping.get(ss.category)
if ss.guid in current_seats:
seat = current_seats.pop(ss.guid)
updated = any([
update(seat, 'product', p),
update(seat, 'name', ss.name),
update(seat, 'row_name', ss.row),
update(seat, 'seat_number', ss.number),
update(seat, 'zone_name', ss.zone),
])
if updated:
seat.save()
else:
create_seats.append(Seat(
event=event,
subevent=subevent,
seat_guid=ss.guid,
name=ss.name,
row_name=ss.row,
seat_number=ss.number,
zone_name=ss.zone,
product=p,
))
for s in current_seats.values():
if s.has_op:
raise SeatProtected(_('You can not change the plan since seat "{}" is not present in the new plan and is '
'already sold.').format(s.name))
Seat.objects.bulk_create(create_seats)
CartPosition.objects.filter(seat__in=[s.pk for s in current_seats.values()]).delete()
Seat.objects.filter(pk__in=[s.pk for s in current_seats.values()]).delete()

View File

@@ -11,14 +11,13 @@ from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from pretix.base.models import CachedFile, Event, cachedfile_name from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.tasks import ProfiledTask from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.shredder import ShredError from pretix.base.shredder import ShredError
from pretix.celery_app import app from pretix.celery_app import app
@app.task(base=ProfiledTask) @app.task(base=ProfiledEventTask)
def export(event: str, shredders: List[str]) -> None: def export(event: Event, shredders: List[str]) -> None:
event = Event.objects.get(id=event)
known_shredders = event.get_data_shredders() known_shredders = event.get_data_shredders()
with NamedTemporaryFile() as rawfile: with NamedTemporaryFile() as rawfile:
@@ -63,9 +62,8 @@ def export(event: str, shredders: List[str]) -> None:
return cf.pk return cf.pk
@app.task(base=ProfiledTask, throws=(ShredError,)) @app.task(base=ProfiledEventTask, throws=(ShredError,))
def shred(event: str, fileid: str, confirm_code: str) -> None: def shred(event: Event, fileid: str, confirm_code: str) -> None:
event = Event.objects.get(id=event)
known_shredders = event.get_data_shredders() known_shredders = event.get_data_shredders()
try: try:
cf = CachedFile.objects.get(pk=fileid) cf = CachedFile.objects.get(pk=fileid)

View File

@@ -1,12 +1,16 @@
from datetime import date, datetime, time, timedelta
from decimal import Decimal from decimal import Decimal
from typing import Any, Dict, Iterable, List, Tuple from typing import Any, Dict, Iterable, List, Tuple
from django.db.models import Case, Count, F, Sum, Value, When from django.db.models import (
Case, Count, DateTimeField, F, Max, OuterRef, Subquery, Sum, Value, When,
)
from django.utils.timezone import make_aware
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition from pretix.base.models import Event, Item, ItemCategory, Order, OrderPosition
from pretix.base.models.event import SubEvent from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee from pretix.base.models.orders import OrderFee, OrderPayment
from pretix.base.signals import order_fee_type_name from pretix.base.signals import order_fee_type_name
@@ -71,8 +75,9 @@ def dictsum(*dicts) -> dict:
return res return res
def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[ItemCategory, List[Item]]], def order_overview(
Dict[str, Tuple[Decimal, Decimal]]]: event: Event, subevent: SubEvent=None, date_filter='', date_from=None, date_until=None
) -> Tuple[List[Tuple[ItemCategory, List[Item]]], Dict[str, Tuple[Decimal, Decimal]]]:
items = event.items.all().select_related( items = event.items.all().select_related(
'category', # for re-grouping 'category', # for re-grouping
).prefetch_related( ).prefetch_related(
@@ -82,6 +87,38 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
qs = OrderPosition.all qs = OrderPosition.all
if subevent: if subevent:
qs = qs.filter(subevent=subevent) qs = qs.filter(subevent=subevent)
if date_from and isinstance(date_from, date):
date_from = make_aware(datetime.combine(
date_from,
time(hour=0, minute=0, second=0, microsecond=0)
), event.timezone)
if date_until and isinstance(date_until, date):
date_until = make_aware(datetime.combine(
date_until + timedelta(days=1),
time(hour=0, minute=0, second=0, microsecond=0)
), event.timezone)
if date_filter == 'order_date':
if date_from:
qs = qs.filter(order__datetime__gte=date_from)
if date_until:
qs = qs.filter(order__datetime__lt=date_until)
elif date_filter == 'last_payment_date':
p_date = OrderPayment.objects.filter(
order=OuterRef('order'),
state__in=[OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED],
payment_date__isnull=False
).values('order').annotate(
m=Max('payment_date')
).values('m').order_by()
qs = qs.annotate(payment_date=Subquery(p_date, output_field=DateTimeField()))
if date_from:
qs = qs.filter(payment_date__gte=date_from)
if date_until:
qs = qs.filter(payment_date__lt=date_until)
counters = qs.filter( counters = qs.filter(
order__event=event order__event=event
).annotate( ).annotate(
@@ -153,14 +190,26 @@ def order_overview(event: Event, subevent: SubEvent=None) -> Tuple[List[Tuple[It
payment_items = [] payment_items = []
if not subevent: if not subevent:
counters = OrderFee.all.filter( qs = OrderFee.all.filter(
order__event=event order__event=event
).annotate( ).annotate(
status=Case( status=Case(
When(canceled=True, then=Value('c')), When(canceled=True, then=Value('c')),
default=F('order__status') default=F('order__status')
) )
).values( )
if date_filter == 'order_date':
if date_from:
qs = qs.filter(order__datetime__gte=date_from)
if date_until:
qs = qs.filter(order__datetime__lt=date_until)
elif date_filter == 'last_payment_date':
qs = qs.annotate(payment_date=Subquery(p_date, output_field=DateTimeField()))
if date_from:
qs = qs.filter(payment_date__gte=date_from)
if date_until:
qs = qs.filter(payment_date__lt=date_until)
counters = qs.values(
'fee_type', 'internal_type', 'status' 'fee_type', 'internal_type', 'status'
).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by() ).annotate(cnt=Count('id'), value=Sum('value'), tax_value=Sum('tax_value')).order_by()

View File

@@ -14,10 +14,12 @@ import time
from django.conf import settings from django.conf import settings
from django.db import transaction from django.db import transaction
from django_scopes import scope, scopes_disabled
from pretix.base.metrics import ( from pretix.base.metrics import (
pretix_task_duration_seconds, pretix_task_runs_total, pretix_task_duration_seconds, pretix_task_runs_total,
) )
from pretix.base.models import Event
from pretix.celery_app import app from pretix.celery_app import app
@@ -61,6 +63,35 @@ class ProfiledTask(app.Task):
return super().on_success(retval, task_id, args, kwargs) return super().on_success(retval, task_id, args, kwargs)
class EventTask(app.Task):
def __call__(self, *args, **kwargs):
if 'event_id' in kwargs:
event_id = kwargs.get('event_id')
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
del kwargs['event_id']
kwargs['event'] = event
elif 'event' in kwargs:
event_id = kwargs.get('event')
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
kwargs['event'] = event
else:
args = list(args)
event_id = args[0]
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
args[0] = event
with scope(organizer=event.organizer):
ret = super().__call__(*args, **kwargs)
return ret
class ProfiledEventTask(ProfiledTask, EventTask):
pass
class TransactionAwareTask(ProfiledTask): class TransactionAwareTask(ProfiledTask):
""" """
Task class which is aware of django db transactions and only executes tasks Task class which is aware of django db transactions and only executes tasks

View File

@@ -4,13 +4,14 @@ import os
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_scopes import scopes_disabled
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order, CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
OrderPosition, OrderPosition,
) )
from pretix.base.services.tasks import ProfiledTask from pretix.base.services.tasks import EventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import allow_ticket_download, register_ticket_outputs from pretix.base.signals import allow_ticket_download, register_ticket_outputs
from pretix.celery_app import app from pretix.celery_app import app
@@ -57,10 +58,11 @@ def generate_order(order: int, provider: str):
@app.task(base=ProfiledTask) @app.task(base=ProfiledTask)
def generate(model: str, pk: int, provider: str): def generate(model: str, pk: int, provider: str):
if model == 'order': with scopes_disabled():
return generate_order(pk, provider) if model == 'order':
elif model == 'orderposition': return generate_order(pk, provider)
return generate_orderposition(pk, provider) elif model == 'orderposition':
return generate_orderposition(pk, provider)
class DummyRollbackException(Exception): class DummyRollbackException(Exception):
@@ -165,9 +167,8 @@ def get_tickets_for_order(order, base_position=None):
return tickets return tickets
@app.task(base=ProfiledTask) @app.task(base=EventTask)
def invalidate_cache(event: int, item: int=None, provider: str=None, order: int=None, **kwargs): def invalidate_cache(event: Event, item: int=None, provider: str=None, order: int=None, **kwargs):
event = Event.objects.get(id=event)
qs = CachedTicket.objects.filter(order_position__order__event=event) qs = CachedTicket.objects.filter(order_position__order__event=event)
qsc = CachedCombinedTicket.objects.filter(order__event=event) qsc = CachedCombinedTicket.objects.filter(order__event=event)

View File

@@ -6,6 +6,7 @@ import requests
from django.dispatch import receiver from django.dispatch import receiver
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _, ugettext_noop from django.utils.translation import ugettext_lazy as _, ugettext_noop
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from pretix import __version__ from pretix import __version__
@@ -29,6 +30,7 @@ def run_update_check(sender, **kwargs):
@app.task @app.task
@scopes_disabled()
def update_check(): def update_check():
gs = GlobalSettingsObject() gs = GlobalSettingsObject()

View File

@@ -1,17 +1,17 @@
import sys import sys
from django.dispatch import receiver from django.dispatch import receiver
from django_scopes import scopes_disabled
from pretix.base.models import Event, User, WaitingListEntry from pretix.base.models import Event, User, WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException from pretix.base.models.waitinglist import WaitingListException
from pretix.base.services.tasks import ProfiledTask from pretix.base.services.tasks import EventTask
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
@app.task(base=ProfiledTask) @app.task(base=EventTask)
def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None): def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None):
event = Event.objects.get(id=event_id)
if user_id: if user_id:
user = User.objects.get(id=user_id) user = User.objects.get(id=user_id)
else: else:
@@ -22,7 +22,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
qs = WaitingListEntry.objects.filter( qs = WaitingListEntry.objects.filter(
event=event, voucher__isnull=True event=event, voucher__isnull=True
).select_related('item', 'variation').prefetch_related( ).select_related('item', 'variation', 'subevent').prefetch_related(
'item__quotas', 'variation__quotas' 'item__quotas', 'variation__quotas'
).order_by('-priority', 'created') ).order_by('-priority', 'created')
@@ -34,12 +34,14 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
with event.lock(): with event.lock():
for wle in qs: for wle in qs:
if (wle.item, wle.variation) in gone: if (wle.item, wle.variation, wle.subevent) in gone:
continue continue
ev = (wle.subevent or event) ev = (wle.subevent or event)
if not ev.presale_is_running or (wle.subevent and not wle.subevent.active): if not ev.presale_is_running or (wle.subevent and not wle.subevent.active):
continue continue
if wle.subevent and not wle.subevent.presale_is_running:
continue
quotas = (wle.variation.quotas.filter(subevent=wle.subevent) quotas = (wle.variation.quotas.filter(subevent=wle.subevent)
if wle.variation if wle.variation
@@ -63,16 +65,17 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize quota_cache[q.pk][1] - 1 if quota_cache[q.pk][1] is not None else sys.maxsize
) )
else: else:
gone.add((wle.item, wle.variation)) gone.add((wle.item, wle.variation, wle.subevent))
return sent return sent
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def process_waitinglist(sender, **kwargs): def process_waitinglist(sender, **kwargs):
qs = Event.objects.filter( qs = Event.objects.filter(
live=True live=True
).prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer') ).prefetch_related('_settings_objects', 'organizer___settings_objects').select_related('organizer')
for e in qs: for e in qs:
if e.settings.waiting_list_auto and e.presale_is_running: if e.settings.waiting_list_auto and (e.presale_is_running or e.has_subevents):
assign_automatically.apply_async(args=(e.pk,)) assign_automatically.apply_async(args=(e.pk,))

View File

@@ -93,6 +93,10 @@ DEFAULTS = {
'default': '', 'default': '',
'type': str, 'type': str,
}, },
'invoice_numbers_prefix_cancellations': {
'default': '',
'type': str,
},
'invoice_renderer': { 'invoice_renderer': {
'default': 'classic', 'default': 'classic',
'type': str, 'type': str,
@@ -701,6 +705,23 @@ Your {event} team"""))
'type': str 'type': str
} }
} }
PERSON_NAME_TITLE_GROUPS = OrderedDict([
('english_common', (_('Most common English titles'), (
'Mr',
'Ms',
'Mrs',
'Miss',
'Mx',
'Dr',
'Professor',
'Sir'
))),
('german_common', (_('Most common German titles'), (
'Dr.',
'Prof.',
'Prof. Dr.',
)))
])
PERSON_NAME_SCHEMES = OrderedDict([ PERSON_NAME_SCHEMES = OrderedDict([
('given_family', { ('given_family', {
'fields': ( 'fields': (
@@ -730,6 +751,22 @@ PERSON_NAME_SCHEMES = OrderedDict([
'_scheme': 'title_given_family', '_scheme': 'title_given_family',
}, },
}), }),
('title_given_family', {
'fields': (
('title', pgettext_lazy('person_name', 'Title'), 1),
('given_name', _('Given name'), 2),
('family_name', _('Family name'), 2),
),
'concatenation': lambda d: ' '.join(
str(p) for p in [d.get('title', ''), d.get('given_name', ''), d.get('family_name', '')] if p
),
'sample': {
'title': pgettext_lazy('person_name_sample', 'Dr'),
'given_name': pgettext_lazy('person_name_sample', 'John'),
'family_name': pgettext_lazy('person_name_sample', 'Doe'),
'_scheme': 'title_given_family',
},
}),
('given_middle_family', { ('given_middle_family', {
'fields': ( 'fields': (
('given_name', _('First name'), 2), ('given_name', _('First name'), 2),

View File

@@ -265,6 +265,21 @@ appropriate exception message.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event. As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
""" """
validate_cart_addons = EventPluginSignal(
providing_args=["addons", "base_position", "iao"]
)
"""
This signal is sent when a user tries to select a combination of addons. In contrast to
``validate_cart``, this is executed before the cart is actually modified. You are passed
an argument ``addons`` containing a set of ``(item, variation or None)`` tuples as well
as the ``ItemAddOn`` object as the argument ``iao`` and the base cart position as
``base_position``.
The response of receivers will be ignored, but you can raise a CartError with an
appropriate exception message.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
order_placed = EventPluginSignal( order_placed = EventPluginSignal(
providing_args=["order"] providing_args=["order"]
) )
@@ -524,3 +539,28 @@ a ``subevent`` argument which might be none and you are expected to return a lis
``pretix.base.timeline.TimelineEvent``, which is a ``namedtuple`` with the fields ``event``, ``subevent``, ``pretix.base.timeline.TimelineEvent``, which is a ``namedtuple`` with the fields ``event``, ``subevent``,
``datetime``, ``description`` and ``edit_url``. ``datetime``, ``description`` and ``edit_url``.
""" """
quota_availability = EventPluginSignal(
providing_args=['quota', 'result', 'count_waitinglist']
)
"""
This signal allows you to modify the availability of a quota. You are passed the ``quota`` and an
``availability`` result calculated by pretix code or other plugins. ``availability`` is a tuple
with the first entry being one of the ``Quota.AVAILABILITY_*`` constants and the second entry being
the number of available tickets (or ``None`` for unlimited). You are expected to return a value
of the same time. The parameter ``count_waitinglists`` specifies whether waiting lists should be taken
into account.
**Warning: Use this signal with great caution, it allows you to screw up the performance of the
system really bad.** Also, keep in mind that your response is subject to caching and out-of-date
quotas might be used for display (not for actual order processing).
"""
order_split = EventPluginSignal(
providing_args=["original", "split_order"]
)
"""
This signal is sent out when an order is split into two orders and allows you to copy related models
to the new order. You will be passed the old order as ``original`` and the new order as ``split_order``.
"""

View File

@@ -159,8 +159,7 @@
<!--[if !mso]><!-- --> <!--[if !mso]><!-- -->
<tr> <tr>
<td> <td>
<img class="wide" src="data:image/png;base64, <img class="wide" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAAAAACf95tlAAAAAXNCSVQI5gpbmQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAG/SURBVHja7dvRboMwDIXhvf/DLiQQAwkku9+qDgq2hPyfN6j1qTlx06/uMunbLMnnhL98fuzRDtYILEeZ7GBNwAIWsIB1LdkOVgaWo4gdLAGWo6x2sFZgOUq1g1WB5SjNDlYDlqcEK1dDB5anmK3eE7C4FnIpBNbVFLo7sB7d3huwKFlULGA9pWQJsJxls4G1ActbooWr2IHlLbMFrBlY7rJbwNqBxb2QZ8nAuiUGO9ICLOo71R1YN0X9td8KLJ8ZeDEDrAd+Za3A4mLIz4TAujGqv+tUYPmN4v8LcweW3zS1t++hActzCrtRYD3pMJQOLOeJ7NyBpZFdoWaFDVjuJ6BRswpTBZbCAn5hpsDq/fbHpDMTBZbC1TAzT2ApyMIVsDROQ2GWwFJo8PR2YP3eOtywzwrsGYD1J9vlHXzcmSKw7q/wU2OEwHpdtALHILA00jJfV8DSaVofvYOPlckB658sp/8VNrBkANahqnXqfhhXJgasgymHD8REZwfWmezzga+tQdhcAet0qry1FYV3osD6dP1QJL3YbYUkhfUCsK6einWRPI0pxjROWZbK+QcsAiwCLEKARYBFgEXIu/wAYbjtwujw8KwAAAAASUVORK5CYII="
iVBORw0KGgoAAAANSUhEUgAAAlgAAAA8CAAAAACf95tlAAAAAXNCSVQI5gpbmQAAAAlwSFlzAAAOxAAADsQBlSsOGwAAABl0RVh0U29mdHdhcmUAd3d3Lmlua3NjYXBlLm9yZ5vuPBoAAAG/SURBVHja7dvRboMwDIXhvf/DLiQQAwkku9+qDgq2hPyfN6j1qTlx06/uMunbLMnnhL98fuzRDtYILEeZ7GBNwAIWsIB1LdkOVgaWo4gdLAGWo6x2sFZgOUq1g1WB5SjNDlYDlqcEK1dDB5anmK3eE7C4FnIpBNbVFLo7sB7d3huwKFlULGA9pWQJsJxls4G1ActbooWr2IHlLbMFrBlY7rJbwNqBxb2QZ8nAuiUGO9ICLOo71R1YN0X9td8KLJ8ZeDEDrAd+Za3A4mLIz4TAujGqv+tUYPmN4v8LcweW3zS1t++hActzCrtRYD3pMJQOLOeJ7NyBpZFdoWaFDVjuJ6BRswpTBZbCAn5hpsDq/fbHpDMTBZbC1TAzT2ApyMIVsDROQ2GWwFJo8PR2YP3eOtywzwrsGYD1J9vlHXzcmSKw7q/wU2OEwHpdtALHILA00jJfV8DSaVofvYOPlckB658sp/8VNrBkANahqnXqfhhXJgasgymHD8REZwfWmezzga+tQdhcAet0qry1FYV3osD6dP1QJL3YbYUkhfUCsK6einWRPI0pxjROWZbK+QcsAiwCLEKARYBFgEXIu/wAYbjtwujw8KwAAAAASUVORK5CYII="
style="max-height: 60px;"> style="max-height: 60px;">
</td> </td>
</tr> </tr>

View File

@@ -1,3 +1,5 @@
import json
from django import template from django import template
from django.template.defaultfilters import stringfilter from django.template.defaultfilters import stringfilter
@@ -11,3 +13,9 @@ register = template.Library()
def escapejs_filter(value): def escapejs_filter(value):
"""Hex encodes characters for use in a application/json type script.""" """Hex encodes characters for use in a application/json type script."""
return escapejson(value) return escapejson(value)
@register.filter("escapejson_dumps")
def escapejs_dumps_filter(value):
"""Hex encodes characters for use in a application/json type script."""
return escapejson(json.dumps(value))

View File

@@ -3,6 +3,7 @@ import hmac
from django.conf import settings from django.conf import settings
from django.http import HttpResponse from django.http import HttpResponse
from django_scopes import scopes_disabled
from .. import metrics from .. import metrics
@@ -15,6 +16,7 @@ def unauthed_response():
return response return response
@scopes_disabled()
def serve_metrics(request): def serve_metrics(request):
if not settings.METRICS_ENABLED: if not settings.METRICS_ENABLED:
return unauthed_response() return unauthed_response()

View File

@@ -5,6 +5,7 @@ from django.conf import settings
from django.db.models import Q from django.db.models import Q
from django.urls import Resolver404, get_script_prefix, resolve from django.urls import Resolver404, get_script_prefix, resolve
from django.utils.translation import get_language from django.utils.translation import get_language
from django_scopes import scope
from pretix.base.models.auth import StaffSession from pretix.base.models.auth import StaffSession
from pretix.base.settings import GlobalSettingsObject from pretix.base.settings import GlobalSettingsObject
@@ -53,10 +54,11 @@ def contextprocessor(request):
ctx['has_domain'] = request.event.organizer.domains.exists() ctx['has_domain'] = request.event.organizer.domains.exists()
if not request.event.testmode: if not request.event.testmode:
complain_testmode_orders = request.event.cache.get('complain_testmode_orders') with scope(organizer=request.organizer):
if complain_testmode_orders is None: complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
complain_testmode_orders = request.event.orders.filter(testmode=True).exists() if complain_testmode_orders is None:
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30) complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
ctx['complain_testmode_orders'] = complain_testmode_orders ctx['complain_testmode_orders'] = complain_testmode_orders
else: else:
ctx['complain_testmode_orders'] = False ctx['complain_testmode_orders'] = False

View File

@@ -1,6 +1,9 @@
from django import forms from django import forms
from django.urls import reverse from django.urls import reverse
from django.utils.translation import pgettext_lazy from django.utils.translation import pgettext_lazy
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from pretix.base.models.checkin import CheckinList from pretix.base.models.checkin import CheckinList
from pretix.control.forms.widgets import Select2 from pretix.control.forms.widgets import Select2
@@ -44,3 +47,7 @@ class CheckinListForm(forms.ModelForm):
'data-inverse-dependency': '<[name$=all_products]' 'data-inverse-dependency': '<[name$=all_products]'
}), }),
} }
field_classes = {
'limit_products': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField,
}

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