Compare commits

..
394 Commits
Author SHA1 Message Date
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 MichelandGitHub 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 MichelandGitHub f55fff6495 Update from Weblate (#1342)
Update from Weblate
2019-07-15 11:01:31 +02:00
Raphael Michelandpretix Translation Platform 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 Michelandpretix Translation Platform 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álezandRaphael Michel 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 MichelandGitHub 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 MichelandGitHub fb66434fc9 Update from Weblate (#1335)
Update from Weblate
2019-07-11 10:37:46 +02:00
Vitor Piedrasandpretix Translation Platform 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 Michelandpretix Translation Platform 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 Michelandpretix Translation Platform 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 MichelandGitHub 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 MichelandGitHub 4c2fe9fc20 Update from Weblate (#1326)
Update from Weblate
2019-07-02 12:41:13 +02:00
Maarten van den Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 GrossandRaphael Michel 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 MichelandGitHub 5326aa7486 Merge pull request #1325 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-06-25 11:33:19 +02:00
Raphael Michelandpretix Translation Platform 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 Michelandpretix Translation Platform 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 MichelandGitHub 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 MichelandGitHub dda48d92c6 Update from Weblate (#1317)
Update from Weblate
2019-06-17 17:08:44 +02:00
Martin GrossandRaphael Michel 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 Axellandpretix Translation Platform 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
Vladandpretix Translation Platform 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
Vladandpretix Translation Platform 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 Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 Bergandpretix Translation Platform 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 MichelandGitHub 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 GrossandRaphael Michel 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 MichelandGitHub 4913190730 Update from Weblate (#1312)
Update from Weblate
2019-06-06 11:25:58 +02:00
Raphael Michelandpretix Translation Platform 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 Michelandpretix Translation Platform 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
Raphael Michel b66a35df7a Bump to 2.8.0 2019-06-05 16:13:37 +02:00
Raphael MichelandGitHub 2e1347cf9a Update from Weblate (#1311)
Update from Weblate
2019-06-05 13:39:11 +02:00
Raphael Michelandpretix Translation Platform 8d1c9e44fc Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3123 of 3123 strings)

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

powered by weblate
2019-06-05 11:38:38 +00:00
Raphael Michelandpretix Translation Platform a187a02daa Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3123 of 3123 strings)

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

powered by weblate
2019-06-05 11:38:37 +00:00
Raphael Michel b9d100b5a8 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-05 12:21:46 +02:00
Raphael MichelandGitHub f1e097c1b1 Update from Weblate (#1309)
Update from Weblate
2019-06-05 12:20:14 +02:00
ThanosTesteandpretix Translation Platform 91a5b1546a Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-06-05 07:18:57 +00:00
ThanosTesteandpretix Translation Platform 3532f9c5a9 Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-06-05 07:18:57 +00:00
ThanosTesteandpretix Translation Platform 884e54180a Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-06-05 07:18:57 +00:00
ThanosTesteandpretix Translation Platform e17ddb0cc8 Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-06-05 07:18:57 +00:00
Raphael Michel 55edc8a3d6 Order change interface: Fix taxation edge case 2019-06-05 09:18:03 +02:00
Raphael Michel bd79a93737 Fix spelling error 2019-06-03 11:53:26 +02:00
Raphael Michel 12ab260eb1 Add documentation for billing_invoices API 2019-06-03 11:29:54 +02:00
Raphael Michel 30f0318de6 API: Add stable and configurable filtering to events and organizers endpoints 2019-06-03 10:19:16 +02:00
Raphael Michel 52e072e68f Dekodi export: Deal with raw Stripe sources
Fix PRETIXEU-14B
2019-06-03 09:59:51 +02:00
Raphael Michel f25bb571b9 Typeahead: Fix ValueError introduced in b3436c1
Fix PRETIXEU-14A
2019-06-03 09:57:35 +02:00
Martin Gross ae71492902 Assign flag for Greek (el) 2019-05-31 12:41:33 +02:00
Raphael MichelandGitHub 57375eb9b6 Update from Weblate (#1308)
Update from Weblate
2019-05-31 10:50:55 +02:00
Raphael Michel 0657ef2e0c Add missing log descriptions 2019-05-31 10:50:10 +02:00
Raphael Michel f63907fb16 Do not show infinitely long logs in sidebars 2019-05-31 10:49:57 +02:00
ThanosTesteandpretix Translation Platform e266d3808f Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-05-30 16:39:05 +00:00
ThanosTesteandpretix Translation Platform 180f9a356f Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-05-30 16:39:05 +00:00
mapostolopoulouandpretix Translation Platform 480b71bd50 Translated on translate.pretix.eu (Greek)
Currently translated at 93.4% (2910 of 3116 strings)

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

powered by weblate
2019-05-30 16:39:05 +00:00
ThanosTesteandpretix Translation Platform 79839e3735 Translated on translate.pretix.eu (Greek)
Currently translated at 93.4% (2910 of 3116 strings)

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

powered by weblate
2019-05-30 16:39:05 +00:00
markiousiandpretix Translation Platform 6ba5c58556 Translated on translate.pretix.eu (Greek)
Currently translated at 93.4% (2910 of 3116 strings)

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

powered by weblate
2019-05-30 16:39:05 +00:00
Raphael Michel a5e3bab107 Fix missing argument in HTML email preview 2019-05-30 18:37:18 +02:00
Raphael Michel 4dcce70ab3 Fix doc spelling 2019-05-29 16:32:22 +02:00
Raphael Michel 8a5332f415 Promote Greek to an inofficial language 2019-05-29 16:09:53 +02:00
Raphael Michel 58ce1cbab7 Allow to configure locale path and incubating languages 2019-05-29 16:09:53 +02:00
Raphael MichelandGitHub 27c3e5d875 Update from Weblate (#1305)
Update from Weblate
2019-05-29 15:57:18 +02:00
ThanosTesteandpretix Translation Platform caac517c0d Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-05-29 13:24:30 +00:00
mapostolopoulouandpretix Translation Platform 58b9052164 Translated on translate.pretix.eu (Greek)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-05-29 13:24:30 +00:00
ThanosTesteandpretix Translation Platform 2d223a9e11 Translated on translate.pretix.eu (Greek)
Currently translated at 96.0% (95 of 99 strings)

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

powered by weblate
2019-05-29 13:22:27 +00:00
mapostolopoulouandpretix Translation Platform fe37ab9286 Translated on translate.pretix.eu (Greek)
Currently translated at 96.0% (95 of 99 strings)

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

powered by weblate
2019-05-29 13:22:08 +00:00
ThanosTesteandpretix Translation Platform 95cc661a05 Translated on translate.pretix.eu (Greek)
Currently translated at 96.0% (95 of 99 strings)

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

powered by weblate
2019-05-29 13:22:08 +00:00
mapostolopoulouandpretix Translation Platform 9a98d16949 Translated on translate.pretix.eu (Greek)
Currently translated at 90.9% (90 of 99 strings)

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

powered by weblate
2019-05-29 13:21:26 +00:00
ThanosTesteandpretix Translation Platform 50ba019a07 Translated on translate.pretix.eu (Greek)
Currently translated at 90.9% (90 of 99 strings)

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

powered by weblate
2019-05-29 13:21:25 +00:00
ThanosTesteandpretix Translation Platform 7d3e9b1777 Translated on translate.pretix.eu (Greek)
Currently translated at 86.9% (86 of 99 strings)

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

powered by weblate
2019-05-29 13:21:07 +00:00
ThanosTesteandpretix Translation Platform f82640d763 Translated on translate.pretix.eu (Greek)
Currently translated at 86.9% (86 of 99 strings)

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

powered by weblate
2019-05-29 13:21:07 +00:00
markiousiandpretix Translation Platform d84cd71a5c Translated on translate.pretix.eu (Greek)
Currently translated at 83.8% (83 of 99 strings)

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

powered by weblate
2019-05-29 13:19:25 +00:00
ThanosTesteandpretix Translation Platform 74105ddd53 Translated on translate.pretix.eu (Greek)
Currently translated at 83.8% (83 of 99 strings)

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

powered by weblate
2019-05-29 13:19:25 +00:00
Andrikopoulos-Giannisandpretix Translation Platform 3a2f915ac9 Translated on translate.pretix.eu (Greek)
Currently translated at 92.3% (2877 of 3116 strings)

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

powered by weblate
2019-05-29 13:14:52 +00:00
ThanosTesteandpretix Translation Platform 9024a552a9 Translated on translate.pretix.eu (Greek)
Currently translated at 92.3% (2877 of 3116 strings)

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

powered by weblate
2019-05-29 13:14:51 +00:00
mapostolopoulouandpretix Translation Platform bae9fab2c4 Translated on translate.pretix.eu (Greek)
Currently translated at 92.3% (2877 of 3116 strings)

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

powered by weblate
2019-05-29 13:14:51 +00:00
markiousiandpretix Translation Platform ee3cd6d465 Translated on translate.pretix.eu (Greek)
Currently translated at 92.3% (2877 of 3116 strings)

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

powered by weblate
2019-05-29 13:14:50 +00:00
ThanosTesteandpretix Translation Platform ccdcd380fa Translated on translate.pretix.eu (Greek)
Currently translated at 91.8% (2859 of 3116 strings)

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

powered by weblate
2019-05-29 13:12:11 +00:00
mapostolopoulouandpretix Translation Platform 3c0f0434cd Translated on translate.pretix.eu (Greek)
Currently translated at 91.8% (2859 of 3116 strings)

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

powered by weblate
2019-05-29 13:12:10 +00:00
markiousiandpretix Translation Platform 58dba57bef Translated on translate.pretix.eu (Greek)
Currently translated at 91.1% (2840 of 3116 strings)

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

powered by weblate
2019-05-29 13:09:49 +00:00
mapostolopoulouandpretix Translation Platform 9178aef323 Translated on translate.pretix.eu (Greek)
Currently translated at 91.1% (2840 of 3116 strings)

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

powered by weblate
2019-05-29 13:09:49 +00:00
ThanosTesteandpretix Translation Platform e9d696ea5e Translated on translate.pretix.eu (Greek)
Currently translated at 91.1% (2840 of 3116 strings)

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

powered by weblate
2019-05-29 13:09:49 +00:00
markiousiandpretix Translation Platform 983ffdd8a8 Translated on translate.pretix.eu (Greek)
Currently translated at 89.7% (2794 of 3116 strings)

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

powered by weblate
2019-05-29 13:05:26 +00:00
mapostolopoulouandpretix Translation Platform 294d47ccfc Translated on translate.pretix.eu (Greek)
Currently translated at 89.7% (2794 of 3116 strings)

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

powered by weblate
2019-05-29 13:05:26 +00:00
ThanosTesteandpretix Translation Platform a14b1a5a14 Translated on translate.pretix.eu (Greek)
Currently translated at 89.7% (2794 of 3116 strings)

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

powered by weblate
2019-05-29 13:05:23 +00:00
Andrikopoulos-Giannisandpretix Translation Platform a28c5f71c9 Translated on translate.pretix.eu (Greek)
Currently translated at 73.5% (2290 of 3116 strings)

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

powered by weblate
2019-05-29 11:52:18 +00:00
ThanosTesteandpretix Translation Platform 35bd9d1c22 Translated on translate.pretix.eu (Greek)
Currently translated at 73.5% (2290 of 3116 strings)

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

powered by weblate
2019-05-29 11:52:18 +00:00
mapostolopoulouandpretix Translation Platform b070fc0297 Translated on translate.pretix.eu (Greek)
Currently translated at 73.5% (2290 of 3116 strings)

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

powered by weblate
2019-05-29 11:52:15 +00:00
markiousiandpretix Translation Platform f7fd3596a6 Translated on translate.pretix.eu (Greek)
Currently translated at 73.5% (2290 of 3116 strings)

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

powered by weblate
2019-05-29 11:52:14 +00:00
markiousiandpretix Translation Platform 3b4f758c82 Translated on translate.pretix.eu (Greek)
Currently translated at 65.8% (2050 of 3116 strings)

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

powered by weblate
2019-05-29 10:36:35 +00:00
Andrikopoulos-Giannisandpretix Translation Platform df8c8f2063 Translated on translate.pretix.eu (Greek)
Currently translated at 65.8% (2050 of 3116 strings)

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

powered by weblate
2019-05-29 10:36:35 +00:00
markiousiandpretix Translation Platform ebb6b5b469 Translated on translate.pretix.eu (Greek)
Currently translated at 65.8% (2050 of 3116 strings)

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

powered by weblate
2019-05-29 10:36:34 +00:00
ThanosTesteandpretix Translation Platform 16ad39bb16 Translated on translate.pretix.eu (Greek)
Currently translated at 65.8% (2050 of 3116 strings)

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

powered by weblate
2019-05-29 10:36:32 +00:00
mapostolopoulouandpretix Translation Platform 6ca65edde9 Translated on translate.pretix.eu (Greek)
Currently translated at 65.8% (2050 of 3116 strings)

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

powered by weblate
2019-05-29 10:36:31 +00:00
markiousiandpretix Translation Platform 02684a0fcd Translated on translate.pretix.eu (Greek)
Currently translated at 55.4% (1727 of 3116 strings)

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

powered by weblate
2019-05-29 09:47:39 +00:00
mapostolopoulouandpretix Translation Platform 141ba6e50d Translated on translate.pretix.eu (Greek)
Currently translated at 55.4% (1727 of 3116 strings)

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

powered by weblate
2019-05-29 09:47:38 +00:00
ThanosTesteandpretix Translation Platform 6681eb1a27 Translated on translate.pretix.eu (Greek)
Currently translated at 55.4% (1727 of 3116 strings)

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

powered by weblate
2019-05-29 09:47:38 +00:00
mapostolopoulouandpretix Translation Platform 2b515ea30c Translated on translate.pretix.eu (Greek)
Currently translated at 54.2% (1690 of 3116 strings)

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

powered by weblate
2019-05-29 09:44:07 +00:00
ThanosTesteandpretix Translation Platform 7997882e24 Translated on translate.pretix.eu (Greek)
Currently translated at 54.2% (1690 of 3116 strings)

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

powered by weblate
2019-05-29 09:44:07 +00:00
markiousiandpretix Translation Platform a8190258a4 Translated on translate.pretix.eu (Greek)
Currently translated at 53.8% (1676 of 3116 strings)

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

powered by weblate
2019-05-29 09:42:12 +00:00
mapostolopoulouandpretix Translation Platform 9376a26709 Translated on translate.pretix.eu (Greek)
Currently translated at 53.8% (1676 of 3116 strings)

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

powered by weblate
2019-05-29 09:42:12 +00:00
mapostolopoulouandpretix Translation Platform d8e2e0e217 Translated on translate.pretix.eu (Greek)
Currently translated at 53.7% (1672 of 3116 strings)

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

powered by weblate
2019-05-29 09:41:18 +00:00
GiorgosPapandpretix Translation Platform f9c942bc6f Translated on translate.pretix.eu (Greek)
Currently translated at 53.7% (1672 of 3116 strings)

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

powered by weblate
2019-05-29 09:41:16 +00:00
markiousiandpretix Translation Platform f9b7696366 Translated on translate.pretix.eu (Greek)
Currently translated at 53.7% (1672 of 3116 strings)

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

powered by weblate
2019-05-29 09:41:15 +00:00
ThanosTesteandpretix Translation Platform 2143135285 Translated on translate.pretix.eu (Greek)
Currently translated at 53.7% (1672 of 3116 strings)

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

powered by weblate
2019-05-29 09:41:14 +00:00
ThanosTesteandpretix Translation Platform 54146bb9e8 Translated on translate.pretix.eu (Greek)
Currently translated at 36.7% (1143 of 3116 strings)

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

powered by weblate
2019-05-29 08:09:15 +00:00
GiorgosPapandpretix Translation Platform d1f702cafd Translated on translate.pretix.eu (Greek)
Currently translated at 36.7% (1143 of 3116 strings)

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

powered by weblate
2019-05-29 08:09:15 +00:00
markiousiandpretix Translation Platform 54e7b8da89 Translated on translate.pretix.eu (Greek)
Currently translated at 36.7% (1143 of 3116 strings)

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

powered by weblate
2019-05-29 08:09:14 +00:00
GiorgosPapandpretix Translation Platform 25af386d87 Translated on translate.pretix.eu (Greek)
Currently translated at 36.0% (1121 of 3116 strings)

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

powered by weblate
2019-05-29 08:04:19 +00:00
markiousiandpretix Translation Platform 51fa9e78dd Translated on translate.pretix.eu (Greek)
Currently translated at 36.0% (1121 of 3116 strings)

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

powered by weblate
2019-05-29 08:04:19 +00:00
ThanosTesteandpretix Translation Platform 6cf244bb4b Translated on translate.pretix.eu (Greek)
Currently translated at 36.0% (1121 of 3116 strings)

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

powered by weblate
2019-05-29 08:04:19 +00:00
mapostolopoulouandpretix Translation Platform 6a0e3b1b46 Translated on translate.pretix.eu (Greek)
Currently translated at 36.0% (1121 of 3116 strings)

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

powered by weblate
2019-05-29 08:04:17 +00:00
mapostolopoulouandpretix Translation Platform 571b0e9aa8 Translated on translate.pretix.eu (Greek)
Currently translated at 29.7% (924 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 8075d3e385 Translated on translate.pretix.eu (Greek)
Currently translated at 29.6% (922 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform 411f5c358f Translated on translate.pretix.eu (Greek)
Currently translated at 29.6% (922 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 94d3eff799 Translated on translate.pretix.eu (Greek)
Currently translated at 79.8% (79 of 99 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
markiousiandpretix Translation Platform a073d66213 Translated on translate.pretix.eu (Greek)
Currently translated at 29.4% (916 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform 7b2dda9cd9 Translated on translate.pretix.eu (Greek)
Currently translated at 29.4% (916 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform e9c66f5bb1 Translated on translate.pretix.eu (Greek)
Currently translated at 29.4% (916 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform 24aa8fc033 Translated on translate.pretix.eu (Greek)
Currently translated at 16.8% (522 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform ee74f75913 Translated on translate.pretix.eu (Greek)
Currently translated at 16.8% (522 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform b484675aeb Translated on translate.pretix.eu (Greek)
Currently translated at 16.8% (522 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform 88379d7c25 Translated on translate.pretix.eu (Greek)
Currently translated at 16.7% (519 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 4e8bdb4427 Translated on translate.pretix.eu (Greek)
Currently translated at 16.7% (519 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform f68c29ca95 Translated on translate.pretix.eu (Greek)
Currently translated at 16.6% (517 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform eb7154a55b Translated on translate.pretix.eu (Greek)
Currently translated at 16.6% (517 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform ed19cc99f3 Translated on translate.pretix.eu (Greek)
Currently translated at 16.6% (516 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform 51ae1e5e33 Translated on translate.pretix.eu (Greek)
Currently translated at 16.6% (516 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 132f8d8cb3 Translated on translate.pretix.eu (Greek)
Currently translated at 16.6% (516 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform cd4b4b98b8 Translated on translate.pretix.eu (Greek)
Currently translated at 16.5% (513 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform 07e0ffd4f3 Translated on translate.pretix.eu (Greek)
Currently translated at 16.5% (513 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 1d2a6d55b9 Translated on translate.pretix.eu (Greek)
Currently translated at 16.5% (513 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform 33b893b0ba Translated on translate.pretix.eu (Greek)
Currently translated at 15.8% (492 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform 03ebe0e528 Translated on translate.pretix.eu (Greek)
Currently translated at 15.8% (492 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 28797b8cc6 Translated on translate.pretix.eu (Greek)
Currently translated at 15.8% (492 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform 691ba3a1a7 Translated on translate.pretix.eu (Greek)
Currently translated at 15.3% (477 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform 656673ccde Translated on translate.pretix.eu (Greek)
Currently translated at 15.3% (477 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 7073622ab3 Translated on translate.pretix.eu (Greek)
Currently translated at 15.3% (477 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform b8f71d2428 Translated on translate.pretix.eu (Greek)
Currently translated at 15.2% (473 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 2a8bdc29f4 Translated on translate.pretix.eu (Greek)
Currently translated at 15.2% (473 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform a6da1bb4e9 Translated on translate.pretix.eu (Greek)
Currently translated at 15.1% (471 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform d1e67d38d9 Translated on translate.pretix.eu (Greek)
Currently translated at 15.1% (471 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform a5214d459c Translated on translate.pretix.eu (Greek)
Currently translated at 15.1% (471 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
markiousiandpretix Translation Platform 9ad2891d17 Translated on translate.pretix.eu (Greek)
Currently translated at 13.6% (424 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform 4b2d55a2fb Translated on translate.pretix.eu (Greek)
Currently translated at 13.6% (424 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
mapostolopoulouandpretix Translation Platform 5cfed32d61 Translated on translate.pretix.eu (Greek)
Currently translated at 13.6% (424 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform 06ccd83921 Translated on translate.pretix.eu (Greek)
Currently translated at 13.6% (424 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
ThanosTesteandpretix Translation Platform ba417b6e3c Translated on translate.pretix.eu (Greek)
Currently translated at 4.3% (134 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
markiousiandpretix Translation Platform f7ed0236f3 Translated on translate.pretix.eu (Greek)
Currently translated at 4.0% (126 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
markiousiandpretix Translation Platform 4491b80786 Translated on translate.pretix.eu (Greek)
Currently translated at 4.0% (125 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform 37bcb520cc Translated on translate.pretix.eu (Greek)
Currently translated at 4.0% (125 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Chris Spyandpretix Translation Platform 782e957c3a Translated on translate.pretix.eu (Greek)
Currently translated at 3.7% (116 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Andrikopoulos-Giannisandpretix Translation Platform 953890c269 Translated on translate.pretix.eu (Greek)
Currently translated at 3.7% (116 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Martin Grossandpretix Translation Platform 60d9c1080a Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Raphael Michelandpretix Translation Platform 364e7cefda Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:38 +00:00
Martin Grossandpretix Translation Platform 33accf3250 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
Raphael Michelandpretix Translation Platform be4d9ac00e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
Andrikopoulos-Giannisandpretix Translation Platform 8ca5e4dd54 Translated on translate.pretix.eu (Greek)
Currently translated at 3.7% (114 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
markiousiandpretix Translation Platform 1394cf3148 Translated on translate.pretix.eu (Greek)
Currently translated at 3.7% (114 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
Andrikopoulos-Giannisandpretix Translation Platform de58b35bf4 Translated on translate.pretix.eu (Greek)
Currently translated at 3.6% (111 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
markiousiandpretix Translation Platform 490b421d53 Translated on translate.pretix.eu (Greek)
Currently translated at 3.6% (111 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
markiousiandpretix Translation Platform 61d45f26dd Translated on translate.pretix.eu (Greek)
Currently translated at 3.4% (105 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
Andrikopoulos-Giannisandpretix Translation Platform a8b0475c6d Translated on translate.pretix.eu (Greek)
Currently translated at 3.4% (105 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
Chris Spyandpretix Translation Platform 31cf94eb02 Translated on translate.pretix.eu (Greek)
Currently translated at 3.4% (105 of 3116 strings)

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

powered by weblate
2019-05-29 07:19:37 +00:00
Raphael Michel dc0590ea91 Fix grammar 2019-05-29 09:19:26 +02:00
Raphael Michel bc5e5d0a27 Fix #1307 -- Document hierarchies in navigation signals 2019-05-29 09:17:43 +02:00
Raphael Michel 0fc448fbd3 Refs #1307 -- fix navigation hierarchies being broken by sorting 2019-05-29 09:17:43 +02:00
Raphael Michel 67d5c1ccad Refs #1307 -- Fix crash when assigning a navigation parent without a children list 2019-05-29 09:17:43 +02:00
Raphael Michel 779ad11640 Timeline: Do not show disabled payment providers 2019-05-29 09:17:43 +02:00
Martin Gross 70e9d9faad Add documentation change for cloning events with testmode-attribute 2019-05-28 12:42:03 +02:00
Martin Gross 51f5b0645a Respect testmode in CloneEventSerializer 2019-05-28 12:38:10 +02:00
Martin Gross b3436c1a93 Prepend current organizer to typeahead 2019-05-28 12:29:54 +02:00
Raphael Michel 9eef5d5d6d Fix failing test after update 2019-05-28 12:07:57 +02:00
Martin Gross e139de3c19 Create new events in Testmode by default 2019-05-28 11:14:03 +02:00
Martin Gross 74f861bd48 Respect Testmode when cloning events 2019-05-28 11:13:20 +02:00
Raphael Michel 35c02f35d7 Event selection: Fix typeahead query 2019-05-28 10:52:34 +02:00
Raphael Michel d5c0b0f71d Event creation: Use select2 for event/organizer selection and properly support admin sessions 2019-05-28 10:42:14 +02:00
Raphael Michel 6c701d66b1 Removed setter for cached_property 2019-05-28 10:37:41 +02:00
Martin GrossandGitHub 8d62b509a2 Fix #1292 -- Fix build of PDF documentation 2019-05-28 10:26:05 +02:00
Raphael Michel 9e0b97e88e Fix #601 -- provide setters for meta_info_data 2019-05-28 10:16:54 +02:00
Raphael Michel 28a5519881 Fix #1270 -- Provide preview for fonts in display settings 2019-05-28 10:07:42 +02:00
Raphael Michel 363826e294 Fix #1292 -- Use standard naming of sphinx document root 2019-05-28 09:50:29 +02:00
Raphael Michel eb8ea6d477 Fix #1296 -- Show last_login in user admin 2019-05-28 09:49:28 +02:00
Raphael Michel 77be4d835b Fix #1301 -- Do not export empty files 2019-05-28 09:43:21 +02:00
Raphael Michel c6390520a7 Warn about hidden product limitations 2019-05-28 09:25:05 +02:00
Raphael Michel 594803ec17 Make product.tax_rule required as soon as tax rules exist to avoid users from screwing up their taxes 2019-05-28 08:59:02 +02:00
Raphael Michel 32ce3a4319 Event list: Ignore invalid filter attributes 2019-05-27 23:04:37 +02:00
Raphael Michel d3f01832fe Fix a bug during validation 2019-05-27 18:27:20 +02:00
lislisandRaphael Michel bba702489d rm superfluous closing anchor
in templates/pretixpresale/events/index.html
2019-05-27 18:26:36 +02:00
Raphael Michel 85fe7e55be Guess and pre-fill invoice address country 2019-05-27 17:48:22 +02:00
Martin Gross 92c9216fbd Adding Greek to incubating languages 2019-05-27 17:02:26 +02:00
Raphael Michel db63e20708 Optimize refresh_quota_caches for less long-running queries 2019-05-27 10:09:29 +02:00
Raphael Michel e2ce35a85b Order details: Do not show empty cancellation section 2019-05-24 13:59:58 +02:00
Martin Gross d39964b021 Show information, when item contained in product bundle is disabled 2019-05-24 12:30:31 +02:00
Martin Gross 59beba5069 Allow to unset QuestionAnswers 2019-05-24 11:15:35 +02:00
Raphael MichelandGitHub 1bd3a63959 Update from Weblate (#1300)
Update from Weblate
2019-05-24 10:45:35 +02:00
Raphael Michelandpretix Translation Platform 1d644e90c9 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-05-24 08:34:07 +00:00
Raphael Michelandpretix Translation Platform e0e66c903e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3116 of 3116 strings)

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

powered by weblate
2019-05-24 08:34:06 +00:00
Raphael Michel bc08bdebb5 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-24 09:42:41 +02:00
Raphael MichelandGitHub edd92ac34d Update from Weblate (#1297)
Update from Weblate
2019-05-24 09:42:04 +02:00
Raphael MichelandGitHub f1bce0c08b Allow to send e-mails to attendees individually (#1299)
* .

* Add a position detail page to the frontend

* Mail templates

* Send mails

* Send reminder email

* Add position support to sendmail plugin

* Add and fix some tests

* Fix failing test on real databases
2019-05-24 09:41:44 +02:00
Raphael Michelandpretix Translation Platform 68c24ebea3 Translated on translate.pretix.eu (German (informal))
Currently translated at 99.9% (3083 of 3084 strings)

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

powered by weblate
2019-05-23 12:33:05 +00:00
Raphael Michel d22a7844ea Fix TypeError PRETIXEU-12W 2019-05-23 14:32:55 +02:00
Martin Gross 6238e1df98 Add Mail sender name option 2019-05-22 16:09:52 +02:00
Martin Gross 6acca4c4ba Show event time in event series cart 2019-05-22 16:09:25 +02:00
Raphael Michel 1a9f6e49d4 Refactor product list into own template 2019-05-22 09:48:07 +02:00
Raphael Michel efa1d2683e rich_text: allow a[class] 2019-05-22 08:20:00 +02:00
Martin Gross 9b39d34f81 Fix style - again 2019-05-21 15:33:07 +02:00
Martin Gross 96c5c8c4ff Fix style 2019-05-21 14:30:38 +02:00
Martin Gross 3254ac36a2 Add option to exclude Sales Channels from invoice generation 2019-05-21 14:18:31 +02:00
Raphael Michel 52d10957a1 Timeline: Fix issues with relative dates 2019-05-19 14:42:19 +02:00
Raphael Michel f9d4669423 Timeline: Fix padding 2019-05-19 14:18:23 +02:00
Raphael Michel 6e220cbbd8 Update djangojs.js to current Django 2019-05-19 14:10:22 +02:00
Raphael MichelandGitHub 036a555374 Update from Weblate (#1295)
Update from Weblate
2019-05-19 13:50:18 +02:00
Raphael Michelandpretix Translation Platform 861a41c95f Translated on translate.pretix.eu (German (informal))
Currently translated at 99.9% (3083 of 3084 strings)

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

powered by weblate
2019-05-19 11:14:43 +00:00
Raphael Michelandpretix Translation Platform e2abc19fe3 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3084 of 3084 strings)

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

powered by weblate
2019-05-19 11:13:59 +00:00
ligiandRaphael Michel 97fc226e07 Document development dependency libenchant1c2a (#1294) 2019-05-19 13:06:24 +02:00
Raphael MichelandGitHub d73c98bff0 Document Debian dependency python-venv (#1293)
Add external dependency python-venv
2019-05-19 13:02:58 +02:00
Raphael MichelandGitHub aa186f7a09 Update setup.rst 2019-05-19 13:02:05 +02:00
ligiandGitHub 1b434b40d2 Add external dependency python-venv
otherwise in the next step I get
```
$ python3 -m venv env
The virtual environment was not created successfully because ensurepip is not
available.  On Debian/Ubuntu systems, you need to install the python3-venv
package using the following command.

    apt-get install python3-venv
```
2019-05-17 19:32:32 +02:00
Raphael Michel 71475c5863 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-17 17:41:30 +02:00
Raphael MichelandGitHub 71b544d951 Update from Weblate (#1291)
Update from Weblate
2019-05-17 17:40:52 +02:00
pretix Translation Platform b0685437f1 Merge branch 'master' of https://github.com/pretix/pretix 2019-05-17 17:39:03 +02:00
Raphael Michel 2d99828eab Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-17 17:33:45 +02:00
pretix translation botandRaphael Michel 2ffc1b8eaf Update from Weblate (#1278)
* Translated on translate.pretix.eu (Slovenian)

Currently translated at 0.1% (1 of 3067 strings)

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

powered by weblate

* Translated on translate.pretix.eu (Slovenian)

Currently translated at 0.3% (9 of 3067 strings)

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

powered by weblate
2019-05-17 17:32:54 +02:00
Bostjan Marusicandpretix Translation Platform 893f47d365 Translated on translate.pretix.eu (Slovenian)
Currently translated at 0.3% (9 of 3067 strings)

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

powered by weblate
2019-05-17 15:32:43 +00:00
Bostjan Marusicandpretix Translation Platform 7de1fca2f4 Translated on translate.pretix.eu (Slovenian)
Currently translated at 0.1% (1 of 3067 strings)

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

powered by weblate
2019-05-17 15:32:43 +00:00
Raphael MichelandGitHub c6b18b31a1 Display a timeline on the dashboard (#1290)
* Timeline data model

* Display timeline

* …

* More events

* Plugin support

* Fix docs typo
2019-05-17 17:32:38 +02:00
Raphael Michel ecc9c7f39f Try to fix a flaky test 2019-05-17 13:09:30 +02:00
Raphael Michel b9aba9cf56 Fix issue in offset calculation of relative dates 2019-05-17 12:37:00 +02:00
Raphael Michel 33f0892052 Adjust tests to c7774dfdb 2019-05-16 12:02:49 +02:00
Raphael Michel 4bf3d48549 Open graph contents for organizer pages 2019-05-16 12:02:19 +02:00
Raphael Michel 32aa4b4f3e Fix #1286 -- Autofill quota names with products 2019-05-16 11:41:30 +02:00
Raphael Michel e1992bb99f Refs #1286 -- Show variations in list of quotas 2019-05-16 11:35:19 +02:00
Raphael Michel 45e98546d6 Add open graph tags to event pages 2019-05-16 11:30:25 +02:00
Raphael Michel c7774dfdb8 Allow to set a custom payment date for manual payments 2019-05-16 11:21:00 +02:00
Raphael Michel 6c582b8f8c Prefix notification emails with the event slug 2019-05-16 11:09:20 +02:00
Raphael Michel 5f82db3949 Fix #1205 -- Layout bug on order page if only some tickets can be downloaded 2019-05-16 10:52:11 +02:00
Raphael Michel 2b818f42cd Raise 404 on opening unknown order
PRETIXEU-12Q
2019-05-16 10:05:58 +02:00
Raphael Michel b19df33dda Fix a bug during deletion of vouchers 2019-05-15 15:57:08 +02:00
Raphael Michel dba8761bc5 Fix a bug around bundles and carts 2019-05-15 15:56:54 +02:00
Raphael Michel 0311c0251a Fix an unlogical comparison in a query 2019-05-15 15:23:02 +02:00
Raphael Michel 5b99bf3623 Fix missing encoding 2019-05-15 09:39:32 +02:00
Raphael Michel 4137e0fc1f Add new signal validate_order 2019-05-15 09:37:34 +02:00
Raphael Michel b32c6033f1 Bump to 2.8.0.dev0 2019-05-15 09:37:34 +02:00
Raphael Michel de0e700fec Store whether we know email addresses are working because links have been clicked 2019-05-15 08:22:53 +02:00
Raphael Michel 00bc5f4fae Fix another crash around original prices 2019-05-14 17:20:22 +02:00
Raphael Michel 6ef3603d9f Allow to add multiple Bcc addresses 2019-05-14 10:18:09 +02:00
Raphael Michel 2c7cefea35 Fix #1279 -- Incorrect initial price value in widget in German locale 2019-05-14 09:08:33 +02:00
Tobias KunzeandRaphael Michel a10b31cacb Remove dead test fixtures (#1284) 2019-05-14 08:55:12 +02:00
Raphael Michel 4e9e925b32 Fix #1282 -- Work around issues in file backends 2019-05-12 14:49:45 +02:00
Raphael Michel f4415cf906 Ensure deterministic test collection order 2019-05-10 09:13:18 +02:00
Raphael Michel bf4fcfd914 Add tests to ensure that we documented all signals 2019-05-10 08:58:42 +02:00
Raphael Michel 7021c178ab Set original_price to TaxedPrice even with variations 2019-05-09 23:30:50 +02:00
Raphael Michel 5d8e3e28d6 Assign flag for zh_Hans 2019-05-09 17:00:30 +02:00
Raphael Michel e89aaf4059 Bump to 2.7.0 2019-05-09 16:20:40 +02:00
Raphael MichelandGitHub db270b3bf2 Update from Weblate (#1276)
Update from Weblate
2019-05-09 14:48:51 +02:00
Raphael Michelandpretix Translation Platform d8b78c3a7a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3067 of 3067 strings)

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

powered by weblate
2019-05-09 12:48:23 +00:00
Raphael Michelandpretix Translation Platform 67c448a29e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3067 of 3067 strings)

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

powered by weblate
2019-05-09 12:48:22 +00:00
Raphael Michelandpretix Translation Platform 5b7906f2a1 Translated on translate.pretix.eu (German (informal))
Currently translated at 99.8% (3060 of 3067 strings)

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

powered by weblate
2019-05-09 12:45:12 +00:00
Raphael Michelandpretix Translation Platform 0612d42607 Translated on translate.pretix.eu (German)
Currently translated at 99.8% (3060 of 3067 strings)

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

powered by weblate
2019-05-09 12:45:12 +00:00
Raphael Michel 83f866034a Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-09 14:45:03 +02:00
Raphael Michel b1fa214869 Clarify a description 2019-05-09 14:44:06 +02:00
Raphael Michel aa53b5235a Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-05-09 14:39:58 +02:00
Raphael MichelandGitHub 61a13256a0 Update from Weblate (#1269)
Update from Weblate
2019-05-09 14:39:16 +02:00
Raphael Michelandpretix Translation Platform 64e2336014 Added translation on translate.pretix.eu (Slovenian) 2019-05-09 11:47:16 +00:00
Bostjan Marusicandpretix Translation Platform 3411abd1e6 Added translation on translate.pretix.eu (Slovenian) 2019-05-09 11:47:16 +00:00
Allan Nordhøyandpretix Translation Platform 2a34e54fae Added translation on translate.pretix.eu (Norwegian Bokmål) 2019-05-09 11:47:16 +00:00
Alvaro Enrique Ruanoandpretix Translation Platform 9863dc35d6 Translated on translate.pretix.eu (Spanish)
Currently translated at 99.0% (3031 of 3063 strings)

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

powered by weblate
2019-05-09 11:47:16 +00:00
Raphael Michel 690883a198 Fix #480 -- Allow plugins to specify a minimum pretix version 2019-05-09 13:46:54 +02:00
Raphael Michel d8ded08a46 Checkin list PDF: Remove date from headline, it's in the page header now 2019-05-09 10:52:42 +02:00
Raphael Michel 4aab5daa57 Fixing import order 2019-05-09 10:22:09 +02:00
Raphael Michel e87628c902 Ensure that we document all signals 2019-05-09 10:02:12 +02:00
Raphael Michel 3c7bf46268 Resolve requests/urllib versions 2019-05-09 10:02:12 +02:00
Raphael Michel a1dacb1897 Remove outdated reference from docs 2019-05-09 10:02:12 +02:00
Raphael Michel 08d5626704 Simplify the future of our migration history 2019-05-09 10:02:12 +02:00
Raphael Michel c8a1481f93 Fix #1154 -- Add country-typed questions 2019-05-09 10:02:12 +02:00
Raphael Michel e7c4121745 Add hidden questions 2019-05-09 10:02:12 +02:00
SohaltandRaphael Michel 35ddd8dd28 Typo (#1274) 2019-05-08 13:13:27 +02:00
Raphael Michel e2ec6eb156 Dekodi: Change semantics of signs 2019-05-08 11:47:57 +02:00
Raphael Michel 42edc4c3aa money_filter: Ignore case of currency 2019-05-07 16:20:24 +02:00
Raphael Michel 1cb2f99f3a Tax calculation of "original prices" 2019-05-06 12:33:21 +02:00
Raphael Michel d4146e08b1 Fix widget tests 2019-05-06 12:06:45 +02:00
Raphael Michel 79ae9b6501 Revert "updatestyles: Fix a TypeError"
This reverts commit 53053f19e4.
2019-05-06 11:43:40 +02:00
Raphael Michel c23f71a19c Widget: Add voucher explanation text 2019-05-06 11:33:48 +02:00
Raphael Michel 53053f19e4 updatestyles: Fix a TypeError 2019-05-06 11:33:36 +02:00
Raphael Michel a42b2d76f6 Add scalability to docs word list 2019-05-06 08:50:56 +02:00
Raphael Michel 51392f73a8 Locking optimizations 2019-05-05 17:31:08 +02:00
Raphael Michel 465a5b01b9 Offload more work to database replica 2019-05-05 17:31:08 +02:00
Raphael Michel 74a6004613 Documentation on scaling 2019-05-05 17:31:08 +02:00
SohaltandRaphael Michel f9fc33eba1 Fix #1266 -- Make references to plugin settings clickable links (#1268) 2019-05-02 09:18:42 +02:00
Raphael Michel 363dc74c31 Dekodi: Try to find correct PayPal ID 2019-05-02 09:12:57 +02:00
Raphael MichelandGitHub efb598e93a Update from Weblate (#1267)
Update from Weblate
2019-05-01 14:13:47 +02:00
Raphael Michelandpretix Translation Platform bcfaf2801d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3063 of 3063 strings)

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

powered by weblate
2019-05-01 12:13:28 +00:00
Raphael Michelandpretix Translation Platform 98db417fe6 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-05-01 12:13:18 +00:00
Raphael Michelandpretix Translation Platform a03ffd949e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3063 of 3063 strings)

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

powered by weblate
2019-05-01 12:12:58 +00:00
Raphael Michelandpretix Translation Platform 88ef46dee9 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (99 of 99 strings)

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

powered by weblate
2019-05-01 12:12:39 +00:00
388 changed files with 112410 additions and 52979 deletions
-2
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
+27
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
--------------- ---------------
@@ -273,6 +282,24 @@ to speed up various operations::
If redis is not configured, pretix will store sessions and locks in the database. If memcached If redis is not configured, pretix will store sessions and locks in the database. If memcached
is configured, memcached will be used for caching instead of redis. is configured, memcached will be used for caching instead of redis.
Translations
------------
pretix comes with a number of translations. Some of them are marked as "incubating", which means
they can usually only be selected in development mode. If you want to use them nevertheless, you
can activate them like this::
[languages]
allow_incubating=pt-br,da
You can also tell pretix about additional paths where it will search for translations::
[languages]
path=/path/to/my/translations
For a given language (e.g. ``pt-br``), pretix will then look in the
specific sub-folder, e.g. ``/path/to/my/translations/pt_BR/LC_MESSAGES/django.po``.
Celery task queue Celery task queue
----------------- -----------------
+21 -21
View File
@@ -192,7 +192,8 @@ that you have a deep understanding of the semantics of your replication mechanis
If you do have a replica, you *can* tell pretix about it :ref:`in your configuration <config-replica>`. If you do have a replica, you *can* tell pretix about it :ref:`in your configuration <config-replica>`.
This way, pretix can offload complex read-only queries to the replica when it is safe to do so. This way, pretix can offload complex read-only queries to the replica when it is safe to do so.
As of pretix 2.6, this is mainly used for search queries in the backend and therefore does not really accelerate sales throughput, but we plan on expanding this in the future. As of pretix 2.7, this is mainly used for search queries in the backend and for rendering the
product list and event lists in the frontend, but we plan on expanding this in the future.
Therefore, for now our clear recommendation is: Try to scale your database vertically and put Therefore, for now our clear recommendation is: Try to scale your database vertically and put
it on the most powerful machine you have available. it on the most powerful machine you have available.
@@ -207,30 +208,29 @@ Having some memory available is good in case of e.g. lots of tasks queuing up du
Feel free to set up a redis cluster for availability but you won't need it for performance in a long time. Feel free to set up a redis cluster for availability but you won't need it for performance in a long time.
The locking issue The limitations
----------------- ---------------
Currently, there is one big issue with scaling pretix. We take the reliability and correctness Up to a certain point, pretix scales really well. However, there are a few things that we consider
of pretix' core features very seriously and one part of this is that we want to make absolutely even more important than scalability, and those are correctness and reliability. We want you to be
sure that pretix never sells the wrong number of tickets or tickets it otherwise shouldn't sell. able to trust that pretix will not sell more tickets than you intended or run into similar error
cases.
However, due to pretix flexibility and complexity, ensuring this in a high-concurrency environment is really hard. Combined with pretix' flexibility and complexity, especially around vouchers and quotas, this creates
We're not just counting down integers, since quota availability in pretix depends on a multitude of factors and requires a handful of complex database queries to calculate. some hard issues. In many cases, we need to fall back to event-global locking for some actions which
We can't rely on database-level locking alone to get this right. are likely to run with high concurrency and cause harm.
Therefore, we currently use a very drastic option: For every event, only one of these locking actions can be run at the same time. Examples for this are
During relevant actions that typically occur with high concurrency, such as creating carts and completing orders, we create a system-wide lock for the event and all other such adding products limited by a quota to a cart, adding items to a cart using a voucher or placing an order
actions need to wait. In essence, this means **for a given event we're only ever selling consisting of cart positions that don't have a valid reservation for much longer. In these cases, it is
one ticket at a time**. currently not realistically possible to exceed selling **approx. 500 orders per minute per event**, even
if you add more hardware.
If you have an unlimited number of tickets, we can apply fewer locking and we've reached **approx.
1500 orders per minute per event** in benchmarks, although even more should be possible.
Therefore, it is currently very unlikely that you will be able to exceed **300-400 tickets per minute per event**. We're working to reduce the number of cases in which this is relevant and thereby improve the possible
throughput. If you want to use pretix for an event with 10,000+ tickets that are likely to be sold out
For events up to a few thousand tickets, this isn't a problem and it's not even desirable to be able to sell much faster: If all tickets are reserved in the first minute, the shop shows a big "sold out" and everyone else goes away. Fifteen to thirty minutes later, depending on your settings, the first shopping carts will expire because people didn't actually go through with the purchase and tickets will become available again. This leads to a much more frustrating shopping experience for those trying to get a ticket than if tickets are sold at a slower pace and the first reservations expire before the last reservations are made. Not selling tickets through quickly (e.g. through a queue system) can do a lot to smooth out this process. within minutes, please get in touch to discuss possible solutions. We'll work something out for you!
That said, we still want to fix this of course and make it possible to achieve much higher
throughput rates. We have some plans on how to soften this limitation, but they require
lots of time and effort to be realized. If you want to use pretix for an event with
10,000+ tickets that will be sold out within minutes, get in touch, we will make it work ;)
.. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/ .. _object storage cluster: https://behind.pretix.eu/2018/03/20/high-available-cdn/
+131
View File
@@ -0,0 +1,131 @@
pretix Hosted billing invoices
==============================
This endpoint allows you to access invoices you received for pretix Hosted. It only contains invoices created starting
November 2017.
.. note:: Only available on pretix Hosted, not on self-hosted pretix instances.
Resource description
--------------------
The resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
invoice_number string Invoice number
date_issued date Invoice date
===================================== ========================== =======================================================
Endpoints
---------
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/
Returns a list of all invoices to a given organizer.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/billing_invoices/ 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": [
{
"invoice_number": "R2019002",
"date_issued": "2019-06-03"
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_issued`` and
its reverse, ``-date_issued``. Default: ``date_issued``.
:param organizer: The ``slug`` field of the organizer to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/
Returns information on one invoice, identified by its invoice number.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/ 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
{
"invoice_number": "R2019002",
"date_issued": "2019-06-03"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/billing_invoices/(invoice_number)/download/
Download an invoice in PDF format.
.. warning:: After we created the invoices, they are placed in review with our accounting department. You will
already see them in the API at this point, but you are not able to download them until they completed
review and are sent to you via email. This usually takes a few hours. If you try to download them
in this time frame, you will receive a status code :http:statuscode:`423`.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/billing_invoices/R2019002/download/ 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/pdf
...
:param organizer: The ``slug`` field of the organizer to fetch
:param invoice_number: The ``invoice_number`` field of the invoice to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 423: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
+12 -1
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,
+1 -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"},
+9 -2
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
+34 -5
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``).
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
@@ -50,6 +54,14 @@ plugins list A list of packa
The ``testmode`` attribute has been added. The ``testmode`` attribute has been added.
.. versionchanged:: 2.8
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
--------- ---------
@@ -95,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"
@@ -112,6 +126,9 @@ Endpoints
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned. :query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned. :query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned. :query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``date_from`` and
``slug``. Keep in mind that ``date_from`` of event series does not really tell you anything.
Default: ``slug``.
:param organizer: The ``slug`` field of a valid organizer :param organizer: The ``slug`` field of a valid organizer
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
@@ -153,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"
@@ -184,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"},
@@ -198,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": {},
@@ -228,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": [
@@ -246,7 +269,7 @@ Endpoints
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/ .. http:post:: /api/v1/organizers/(organizer)/events/(event)/clone/
Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public', Creates a new event with properties as set in the request body. The properties that are copied are: 'is_public',
settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions. `testmode`, settings, plugin settings, items, variations, add-ons, quotas, categories, tax rules, questions.
If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise If the 'plugins' and/or 'is_public' fields are present in the post body this will determine their value. Otherwise
their value will be copied from the existing event. their value will be copied from the existing event.
@@ -262,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"},
@@ -277,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": [
@@ -307,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",
@@ -335,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": [
@@ -368,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",
+2
View File
@@ -23,3 +23,5 @@ Resources and endpoints
waitinglist waitinglist
carts carts
webhooks webhooks
seatingplans
billing_invoices
+1 -1
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,
+1 -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,
+1 -1
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"},
+12 -1
View File
@@ -72,6 +72,8 @@ 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.
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 +144,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`` attribute has been added.
Notes Notes
----- -----
@@ -207,6 +213,7 @@ Endpoints
"checkin_attention": false, "checkin_attention": false,
"has_variations": false, "has_variations": false,
"generate_tickets": null, "generate_tickets": null,
"show_quota_left": null,
"require_approval": false, "require_approval": false,
"require_bundling": false, "require_bundling": false,
"variations": [ "variations": [
@@ -294,6 +301,7 @@ Endpoints
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"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 +350,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,
@@ -366,6 +374,7 @@ Endpoints
"hide_without_voucher": false, "hide_without_voucher": false,
"allow_cancel": true, "allow_cancel": true,
"generate_tickets": null, "generate_tickets": null,
"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,
@@ -427,6 +436,7 @@ Endpoints
"min_per_order": null, "min_per_order": null,
"max_per_order": null, "max_per_order": null,
"generate_tickets": null, "generate_tickets": null,
"show_quota_left": null,
"checkin_attention": false, "checkin_attention": false,
"has_variations": true, "has_variations": true,
"require_approval": false, "require_approval": false,
@@ -515,6 +525,7 @@ Endpoints
"require_voucher": false, "require_voucher": false,
"hide_without_voucher": false, "hide_without_voucher": false,
"generate_tickets": null, "generate_tickets": null,
"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,
+14 -3
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,
+2
View File
@@ -56,6 +56,8 @@ Endpoints
} }
:query page: The page number in case of a multi-page result set, default is 1 :query page: The page number in case of a multi-page result set, default is 1
:query string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and
``name``. Default: ``slug``.
:statuscode 200: no error :statuscode 200: no error
:statuscode 401: Authentication failure :statuscode 401: Authentication failure
+28 -7
View File
@@ -30,6 +30,7 @@ type string The expected ty
* ``D`` date * ``D`` date
* ``H`` time * ``H`` time
* ``W`` date and time * ``W`` date and time
* ``CC`` country code (ISO 3666-1 alpha-2)
required boolean If ``true``, the question needs to be filled out. required boolean If ``true``, the question needs to be filled out.
position integer An integer, used for sorting position integer An integer, used for sorting
items list of integers List of item IDs this question is assigned to. items list of integers List of item IDs this question is assigned to.
@@ -38,6 +39,8 @@ identifier string An arbitrary st
ask_during_checkin boolean If ``true``, this question will not be asked while ask_during_checkin boolean If ``true``, this question will not be asked while
buying the ticket, but will show up when redeeming buying the ticket, but will show up when redeeming
the ticket instead. the ticket instead.
hidden boolean If ``true``, the question will only be shown in the
backend.
options list of objects In case of question type ``C`` or ``M``, this lists the options list of objects In case of question type ``C`` or ``M``, this lists the
available objects. Only writable during creation, available objects. Only writable during creation,
use separate endpoint to modify this later. use separate endpoint to modify this later.
@@ -51,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
@@ -68,6 +72,14 @@ dependency_value string The value ``dep
Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the Write methods have been added. The attribute ``identifier`` has been added to both the resource itself and the
options resource. The ``position`` attribute has been added to the options resource. options resource. The ``position`` attribute has been added to the options resource.
.. versionchanged:: 2.7
The attribute ``hidden`` and the question type ``CC`` have been added.
.. versionchanged:: 3.0
The attribute ``dependency_values`` has been added.
Endpoints Endpoints
--------- ---------
@@ -110,8 +122,10 @@ Endpoints
"position": 1, "position": 1,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -177,8 +191,10 @@ Endpoints
"position": 1, "position": 1,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -219,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"},
@@ -228,8 +244,9 @@ Endpoints
"items": [1, 2], "items": [1, 2],
"position": 1, "position": 1,
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_values": [],
"options": [ "options": [
{ {
"answer": {"en": "S"} "answer": {"en": "S"}
@@ -261,8 +278,10 @@ Endpoints
"position": 1, "position": 1,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -332,8 +351,10 @@ Endpoints
"position": 2, "position": 2,
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
+26 -6
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
+209
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
+23 -3
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,
+5
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
--------- ---------
+1 -1
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,
+2 -2
View File
@@ -66,7 +66,7 @@ source_suffix = '.rst'
#source_encoding = 'utf-8-sig' #source_encoding = 'utf-8-sig'
# The master toctree document. # The master toctree document.
master_doc = 'contents' master_doc = 'index'
# General information about the project. # General information about the project.
project = 'pretix' project = 'pretix'
@@ -234,7 +234,7 @@ latex_elements = {
# (source start file, target name, title, # (source start file, target name, title,
# author, documentclass [howto, manual, or own class]). # author, documentclass [howto, manual, or own class]).
latex_documents = [ latex_documents = [
('contents', 'pretix.tex', 'pretix Documentation', ('index', 'pretix.tex', 'pretix Documentation',
'Raphael Michel', 'manual'), 'Raphael Michel', 'manual'),
] ]
+5 -5
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 item_copy_data, register_sales_channels, register_global_settings, quota_availability
Order events Order events
"""""""""""" """"""""""""
@@ -20,13 +20,13 @@ 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, 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 :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 .. automodule:: pretix.presale.signals
@@ -49,11 +49,11 @@ 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, nav_item, subevent_forms
.. automodule:: pretix.base.signals .. automodule:: pretix.base.signals
:members: logentry_display, logentry_object_link, requiredaction_display :members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
Vouchers Vouchers
"""""""" """"""""
+7 -2
View File
@@ -49,15 +49,19 @@ description string A more verbose description of what your
visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated. visible boolean (optional) ``True`` by default, can hide a plugin so it cannot be normally activated.
restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled restricted boolean (optional) ``False`` by default, restricts a plugin such that it can only be enabled
for an event by system administrators / superusers. for an event by system administrators / superusers.
compatibility string Specifier for compatible pretix versions.
================== ==================== =========================================================== ================== ==================== ===========================================================
A working example would be:: A working example would be::
from django.apps import AppConfig try:
from pretix.base.plugins import PluginConfig
except ImportError:
raise RuntimeError("Please use pretix 2.7 or above to run this plugin!")
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
class PaypalApp(AppConfig): class PaypalApp(PluginConfig):
name = 'pretix_paypal' name = 'pretix_paypal'
verbose_name = _("PayPal") verbose_name = _("PayPal")
@@ -68,6 +72,7 @@ A working example would be::
visible = True visible = True
restricted = False restricted = False
description = _("This plugin allows you to receive payments via PayPal") description = _("This plugin allows you to receive payments via PayPal")
compatibility = "pretix>=2.7.0"
default_app_config = 'pretix_paypal.PaypalApp' default_app_config = 'pretix_paypal.PaypalApp'
+1 -1
View File
@@ -23,7 +23,7 @@ Organizers and events
:members: :members:
.. autoclass:: pretix.base.models.Event .. autoclass:: pretix.base.models.Event
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, active_subevents, invoice_renderer, settings :members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running, cache, lock, get_plugins, get_mail_backend, payment_term_last, get_payment_providers, get_invoice_renderers, invoice_renderer, settings
.. autoclass:: pretix.base.models.SubEvent .. autoclass:: pretix.base.models.SubEvent
:members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running :members: get_date_from_display, get_time_from_display, get_date_to_display, get_date_range_display, presale_has_ended, presale_is_running
+3 -3
View File
@@ -21,10 +21,12 @@ Your should install the following on your system:
* Python 3.5 or newer * Python 3.5 or newer
* ``pip`` for Python 3 (Debian package: ``python3-pip``) * ``pip`` for Python 3 (Debian package: ``python3-pip``)
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``) * ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
* On Debian/Ubuntu: ``python-venv`` for Python 3 (Debian package: ``python3-venv``)
* ``libffi`` (Debian package: ``libffi-dev``) * ``libffi`` (Debian package: ``libffi-dev``)
* ``libssl`` (Debian package: ``libssl-dev``) * ``libssl`` (Debian package: ``libssl-dev``)
* ``libxml2`` (Debian package ``libxml2-dev``) * ``libxml2`` (Debian package ``libxml2-dev``)
* ``libxslt`` (Debian package ``libxslt1-dev``) * ``libxslt`` (Debian package ``libxslt1-dev``)
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
* ``msgfmt`` (Debian package ``gettext``) * ``msgfmt`` (Debian package ``gettext``)
* ``git`` * ``git``
@@ -63,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
+2
View File
@@ -40,6 +40,7 @@ frontend
frontpage frontpage
gettext gettext
gunicorn gunicorn
guid
hardcoded hardcoded
hostname hostname
idempotency idempotency
@@ -96,6 +97,7 @@ renderer
renderers renderers
reportlab reportlab
SaaS SaaS
scalability
screenshot screenshot
scss scss
searchable searchable
+42
View File
@@ -85,8 +85,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 +123,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
----------------------------- -----------------------------
+5
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
------------- -------------
-71
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)
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.6.0" __version__ = "2.9.0.dev0"
+3 -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.')
+1 -4
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):
+21
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)
+22 -4
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:
+139 -3
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)
@@ -164,6 +238,7 @@ class CloneEventSerializer(EventSerializer):
def create(self, validated_data): def create(self, validated_data):
plugins = validated_data.pop('plugins', None) plugins = validated_data.pop('plugins', None)
is_public = validated_data.pop('is_public', None) is_public = validated_data.pop('is_public', None)
testmode = validated_data.pop('testmode', None)
new_event = super().create(validated_data) new_event = super().create(validated_data)
event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first() event = Event.objects.filter(slug=self.context['event'], organizer=self.context['organizer'].pk).first()
@@ -173,6 +248,8 @@ class CloneEventSerializer(EventSerializer):
new_event.set_active_plugins(plugins) new_event.set_active_plugins(plugins)
if is_public is not None: if is_public is not None:
new_event.is_public = is_public new_event.is_public = is_public
if testmode is not None:
new_event.testmode = testmode
new_event.save() new_event.save()
return new_event return new_event
@@ -193,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)
@@ -222,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 {
@@ -239,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:
@@ -254,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
@@ -261,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)}
@@ -297,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
+16 -3
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')
read_only_fields = ('has_variations', 'picture') read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self): def get_serializer_context(self):
@@ -200,14 +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', '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)
@@ -261,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:
@@ -272,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)
+35 -9
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,8 +625,8 @@ 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 = {}
@@ -630,7 +640,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 +669,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})
+13 -1
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')
+1 -1
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
+3
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()
+1
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)
+1 -1
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()
+15 -11
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,
) )
+82 -72
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):
@@ -72,6 +73,8 @@ class EventViewSet(viewsets.ModelViewSet):
lookup_url_kwarg = 'event' lookup_url_kwarg = 'event'
permission_classes = (EventCRUDPermission,) permission_classes = (EventCRUDPermission,)
filter_backends = (DjangoFilterBackend, filters.OrderingFilter) filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
ordering = ('slug',)
ordering_fields = ('date_from', 'slug')
filterset_class = EventFilter filterset_class = EventFilter
def get_queryset(self): def get_queryset(self):
@@ -83,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):
@@ -180,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):
@@ -238,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,
+50 -19
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,
+66 -62
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',
) )
) )
) )
@@ -482,6 +483,7 @@ class OrderViewSet(viewsets.ModelViewSet):
) )
if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'): if 'email' in self.request.data and serializer.instance.email != self.request.data.get('email'):
serializer.instance.email_known_to_work = False
serializer.instance.log_action( serializer.instance.log_action(
'pretix.event.order.contact.changed', 'pretix.event.order.contact.changed',
user=self.request.user, user=self.request.user,
@@ -530,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',)
@@ -608,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
@@ -959,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):
+57 -3
View File
@@ -1,8 +1,12 @@
from rest_framework import 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):
@@ -10,6 +14,9 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Organizer.objects.none() queryset = Organizer.objects.none()
lookup_field = 'slug' lookup_field = 'slug'
lookup_url_kwarg = 'organizer' lookup_url_kwarg = 'organizer'
filter_backends = (filters.OrderingFilter,)
ordering = ('slug',)
ordering_fields = ('name', 'slug')
def get_queryset(self): def get_queryset(self):
if self.request.user.is_authenticated: if self.request.user.is_authenticated:
@@ -27,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()
+15 -14
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):
+9 -8
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):
+46 -44
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
+8 -3
View File
@@ -8,7 +8,7 @@ from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
from pretix.base.models import Event, Order from pretix.base.models import Event, Order, OrderPosition
from pretix.base.signals import register_html_mail_renderers from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
@@ -44,7 +44,8 @@ class BaseHTMLMailRenderer:
def __str__(self): def __str__(self):
return self.identifier return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None,
position: OrderPosition=None) -> str:
""" """
This method should generate the HTML part of the email. This method should generate the HTML part of the email.
@@ -52,6 +53,7 @@ class BaseHTMLMailRenderer:
:param plain_signature: The signature with event organizer contact details in plain text. :param plain_signature: The signature with event organizer contact details in plain text.
:param subject: The email subject. :param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``. :param order: The order if this email is connected to one, otherwise ``None``.
:param position: The order position if this email is connected to one, otherwise ``None``.
:return: An HTML string :return: An HTML string
""" """
raise NotImplementedError() raise NotImplementedError()
@@ -95,7 +97,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self): def template_name(self):
raise NotImplementedError() raise NotImplementedError()
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order: Order, position: OrderPosition) -> str:
body_md = markdown_compile_email(plain_body) body_md = markdown_compile_email(plain_body)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
@@ -116,6 +118,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if order: if order:
htmlctx['order'] = order htmlctx['order'] = order
if position:
htmlctx['position'] = position
tpl = get_template(self.template_name) tpl = get_template(self.template_name)
body_html = inline_css(tpl.render(htmlctx)) body_html = inline_css(tpl.render(htmlctx))
return body_html return body_html
+55 -32
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)
+4
View File
@@ -40,6 +40,7 @@ class AnswerFilesExporter(BaseExporter):
if form_data.get('questions'): if form_data.get('questions'):
qs = qs.filter(question__in=form_data['questions']) qs = qs.filter(question__in=form_data['questions'])
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
any = False
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf: with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs: for i in qs:
if i.file: if i.file:
@@ -51,9 +52,12 @@ class AnswerFilesExporter(BaseExporter):
i.question.pk, i.question.pk,
os.path.basename(i.file.name).split('.', 1)[1] os.path.basename(i.file.name).split('.', 1)[1]
) )
any = True
zipf.writestr(fname, i.file.read()) zipf.writestr(fname, i.file.read())
i.file.close() i.file.close()
if not any:
return None
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf: with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read() return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read()
+5 -5
View File
@@ -30,13 +30,13 @@ class DekodiNREIExporter(BaseExporter):
for l in invoice.lines.all(): for l in invoice.lines.all():
positions.append({ positions.append({
'ADes': l.description.replace("<br />", "\n"), 'ADes': l.description.replace("<br />", "\n"),
'ANetA': round(float(l.net_value), 2), 'ANetA': round(float((-1 if invoice.is_cancellation else 1) * l.net_value), 2),
'ANo': self.event.slug, 'ANo': self.event.slug,
'AQ': -1 if invoice.is_cancellation else 1, 'AQ': -1 if invoice.is_cancellation else 1,
'AVatP': round(float(l.tax_rate), 2), 'AVatP': round(float(l.tax_rate), 2),
'DIDt': (l.subevent or invoice.order.event).date_from.isoformat().replace('Z', '+00:00'), 'DIDt': (l.subevent or invoice.order.event).date_from.isoformat().replace('Z', '+00:00'),
'PosGrossA': round(float((-1 if invoice.is_cancellation else 1) * l.gross_value), 2), 'PosGrossA': round(float(l.gross_value), 2),
'PosNetA': round(float((-1 if invoice.is_cancellation else 1) * l.net_value), 2), 'PosNetA': round(float(l.net_value), 2),
}) })
gross_total += l.gross_value gross_total += l.gross_value
net_total += l.net_value net_total += l.net_value
@@ -50,7 +50,7 @@ class DekodiNREIExporter(BaseExporter):
if p.provider == 'paypal': if p.provider == 'paypal':
paypal_email = p.info_data.get('payer', {}).get('payer_info', {}).get('email') paypal_email = p.info_data.get('payer', {}).get('payer_info', {}).get('email')
try: try:
ppid = p.info_data['transactions'][0]['related_resources']['sale']['id'] ppid = p.info_data['transactions'][0]['related_resources'][0]['sale']['id']
except: except:
ppid = p.info_data.get('id') ppid = p.info_data.get('id')
payments.append({ payments.append({
@@ -93,7 +93,7 @@ class DekodiNREIExporter(BaseExporter):
'PTNo15': p.full_id or '', 'PTNo15': p.full_id or '',
}) })
elif p.provider.startswith('stripe'): elif p.provider.startswith('stripe'):
src = p.info_data.get("source", "{}") src = p.info_data.get("source", p.info_data)
payments.append({ payments.append({
'PTID': '81', 'PTID': '81',
'PTN': 'Stripe', 'PTN': 'Stripe',
+13 -4
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'):
@@ -46,7 +46,8 @@ class InvoiceExporter(BaseExporter):
qs = qs.filter(date__lte=date_value) qs = qs.filter(date__lte=date_value)
with tempfile.TemporaryDirectory() as d: with tempfile.TemporaryDirectory() as d:
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf: any = False
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:
@@ -54,16 +55,24 @@ class InvoiceExporter(BaseExporter):
i.refresh_from_db() i.refresh_from_db()
i.file.open('rb') i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read()) zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close() i.file.close()
except FileNotFoundError: except FileNotFoundError:
invoice_pdf_task.apply(args=(i.pk,)) invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db() i.refresh_from_db()
i.file.open('rb') i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read()) zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close() i.file.close()
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf: if not any:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read() return None
if output_file:
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):
+82 -21
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,9 +11,13 @@ 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 ugettext_lazy as _ from django.utils.translation import get_language, ugettext_lazy as _
from django_countries import countries
from django_countries.fields import Country, CountryField
from pretix.base.forms.widgets import ( from pretix.base.forms.widgets import (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget, BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
@@ -19,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
@@ -31,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):
@@ -97,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
) )
@@ -154,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),
) )
@@ -213,6 +240,14 @@ class BaseQuestionsForm(forms.Form):
widget=forms.Textarea, widget=forms.Textarea,
initial=initial.answer if initial else None, initial=initial.answer if initial else None,
) )
elif q.type == Question.TYPE_COUNTRYCODE:
field = CountryField().formfield(
label=label, required=required,
help_text=help_text,
widget=forms.Select,
empty_label='',
initial=initial.answer if initial else None,
)
elif q.type == Question.TYPE_CHOICE: elif q.type == Question.TYPE_CHOICE:
field = forms.ModelChoiceField( field = forms.ModelChoiceField(
queryset=q.options, queryset=q.options,
@@ -267,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
@@ -288,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:
@@ -342,6 +375,27 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.request = kwargs.pop('request', None) self.request = kwargs.pop('request', None)
self.validate_vat_id = kwargs.pop('validate_vat_id') self.validate_vat_id = kwargs.pop('validate_vat_id')
self.all_optional = kwargs.pop('all_optional', False) self.all_optional = kwargs.pop('all_optional', False)
kwargs.setdefault('initial', {})
if not kwargs.get('instance') or not kwargs['instance'].country:
# Try to guess the initial country from either the country of the merchant
# or the locale. This will hopefully save at least some users some scrolling :)
locale = get_language()
country = event.settings.invoice_address_from_country
if not country:
valid_countries = countries.countries
if '-' in locale:
parts = locale.split('-')
if parts[1].upper() in valid_countries:
country = Country(parts[1].upper())
elif parts[0].upper() in valid_countries:
country = Country(parts[0].upper())
else:
if locale in valid_countries:
country = Country(locale.upper())
kwargs['initial']['country'] = country
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
if not event.settings.invoice_address_vatid: if not event.settings.invoice_address_vatid:
del self.fields['vat_id'] del self.fields['vat_id']
@@ -367,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),
) )
@@ -393,6 +448,12 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.instance.name_parts = data.get('name_parts') self.instance.name_parts = data.get('name_parts')
if all(
not v for k, v in data.items() if k not in ('is_business', 'country', 'name_parts')
) and len(data.get('name_parts', {})) == 1:
# Do not save the country if it is the only field set -- we don't know the user even checked it!
self.cleaned_data['country'] = ''
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'): elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
@@ -414,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:
@@ -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)
@@ -0,0 +1,51 @@
"""
Django, for theoretically very valid reasons, creates migrations for *every single thing*
we change on a model. Even the `help_text`! This makes sense, as we don't know if any
database backend unknown to us might actually use this information for its database schema.
However, pretix only supports PostgreSQL, MySQL, MariaDB and SQLite and we can be pretty
certain that some changes to models will never require a change to the database. In this case,
not creating a migration for certain changes will save us some performance while applying them
*and* allow for a cleaner git history. Win-win!
Only caveat is that we need to do some dirty monkeypatching to achieve it...
"""
from django.core.management.commands.makemigrations import Command as Parent
from django.db import models
from django.db.migrations.operations import models as modelops
from django_countries.fields import CountryField
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("verbose_name_plural")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("ordering")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("get_latest_by")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_manager_name")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("permissions")
modelops.AlterModelOptions.ALTER_OPTION_KEYS.remove("default_permissions")
IGNORED_ATTRS = [
# (field type, attribute name, blacklist of field sub-types)
(models.Field, 'verbose_name', []),
(models.Field, 'help_text', []),
(models.Field, 'validators', []),
(models.Field, 'editable', [models.DateField, models.DateTimeField, models.DateField, models.BinaryField]),
(models.Field, 'blank', [models.DateField, models.DateTimeField, models.AutoField, models.NullBooleanField,
models.TimeField]),
(models.CharField, 'choices', [CountryField])
]
original_deconstruct = models.Field.deconstruct
def new_deconstruct(self):
name, path, args, kwargs = original_deconstruct(self)
for ftype, attr, blacklist in IGNORED_ATTRS:
if isinstance(self, ftype) and not any(isinstance(self, ft) for ft in blacklist):
kwargs.pop(attr, None)
return name, path, args, kwargs
models.Field.deconstruct = new_deconstruct
class Command(Parent):
pass
@@ -0,0 +1,28 @@
"""
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
"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 filtering it from the output.
"""
import sys
from django.core.management.base import OutputWrapper
from django.core.management.commands.migrate import Command as Parent
class OutputFilter(OutputWrapper):
blacklist = (
"Your models have changes that are not yet reflected",
"Run 'manage.py makemigrations' to make new "
)
def write(self, msg, style_func=None, ending=None):
if any(b in msg for b in self.blacklist):
return
super().write(msg, style_func, ending)
class Command(Parent):
def __init__(self, stdout=None, stderr=None, no_color=False, force_color=False):
super().__init__(stdout, stderr, no_color, force_color)
self.stdout = OutputFilter(stdout or sys.stdout)
@@ -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)
@@ -0,0 +1,22 @@
# Generated by Django 2.2 on 2019-05-09 06:54
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0118_auto_20190423_0839'),
]
operations = [
migrations.AddField(
model_name='question',
name='hidden',
field=models.BooleanField(default=False, help_text='This question will only show up in the backend.', verbose_name='Hidden question'),
),
]
@@ -0,0 +1,77 @@
# Generated by Django 2.2 on 2019-05-09 07:36
import django.db.models.deletion
import jsonfallback.fields
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0119_auto_20190509_0654'),
]
operations = [
migrations.AlterField(
model_name='cartposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='cartposition',
name='subevent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
),
migrations.AlterField(
model_name='cartposition',
name='voucher',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
),
migrations.AlterField(
model_name='event',
name='is_public',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='invoiceaddress',
name='name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='item',
name='sales_channels',
field=pretix.base.models.fields.MultiStringField(default=['web']),
),
migrations.AlterField(
model_name='order',
name='sales_channel',
field=models.CharField(default='web', max_length=190),
),
migrations.AlterField(
model_name='orderposition',
name='attendee_name_parts',
field=jsonfallback.fields.FallbackJSONField(default=dict),
),
migrations.AlterField(
model_name='orderposition',
name='subevent',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.SubEvent'),
),
migrations.AlterField(
model_name='orderposition',
name='voucher',
field=models.ForeignKey(null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Voucher'),
),
migrations.AlterField(
model_name='staffsessionauditlog',
name='method',
field=models.CharField(max_length=255),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(db_index=True, max_length=190, null=True, unique=True),
),
]
@@ -0,0 +1,18 @@
# Generated by Django 2.2.1 on 2019-05-15 05:05
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0120_auto_20190509_0736'),
]
operations = [
migrations.AddField(
model_name='order',
name='email_known_to_work',
field=models.BooleanField(default=False),
),
]
@@ -0,0 +1,20 @@
# Generated by Django 2.2.1 on 2019-05-15 13:23
from django.db import migrations, models
import pretix.base.models.orders
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0121_order_email_known_to_work'),
]
operations = [
migrations.AddField(
model_name='orderposition',
name='web_secret',
field=models.CharField(db_index=True, default=pretix.base.models.orders.generate_secret, max_length=32),
),
]
@@ -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'),
),
]
@@ -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,
),
]
@@ -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,
)
]
@@ -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(),
),
]
@@ -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=['']),
),
]
@@ -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),
),
]
+1
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
+3
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.
+38 -2
View File
@@ -6,7 +6,9 @@ from django.db import models
from django.db.models.constants import LOOKUP_SEP from django.db.models.constants import LOOKUP_SEP
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.utils.crypto import get_random_string from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from pretix.helpers.json import CustomJSONEncoder from pretix.helpers.json import CustomJSONEncoder
@@ -84,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:
@@ -113,6 +115,40 @@ class LoggedModel(models.Model, LoggingMixin):
class Meta: class Meta:
abstract = True abstract = True
@cached_property
def logs_content_type(self):
return ContentType.objects.get_for_model(type(self))
@cached_property
def all_logentries_link(self):
from pretix.base.models import Event
if isinstance(self, Event):
event = self
elif hasattr(self, 'event'):
event = self.event
else:
return None
return reverse(
'control:event.log',
kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
}
) + '?content_type={}&object={}'.format(
self.logs_content_type.pk,
self.pk
)
def top_logentries(self):
qs = self.all_logentries()
if self.all_logentries_link:
qs = qs[:25]
return qs
def top_logentries_has_more(self):
return self.all_logentries().count() > 25
def all_logentries(self): def all_logentries(self):
""" """
Returns all log entries that are attached to this object. Returns all log entries that are attached to this object.
@@ -122,7 +158,7 @@ class LoggedModel(models.Model, LoggingMixin):
from .log import LogEntry from .log import LogEntry
return LogEntry.objects.filter( return LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk content_type=self.logs_content_type, object_id=self.pk
).select_related('user', 'event', 'oauth_application', 'api_token', 'device') ).select_related('user', 'event', 'oauth_application', 'api_token', 'device')
+5
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'),)
+6
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'),)
+86 -6
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))
@@ -164,7 +165,7 @@ class EventMixin:
def annotated(cls, qs, channel='web'): def annotated(cls, qs, channel='web'):
from pretix.base.models import Item, ItemVariation, Quota from pretix.base.models import Item, ItemVariation, Quota
sq_active_item = Item.objects.filter_available(channel=channel).filter( sq_active_item = Item.objects.using(settings.DATABASE_REPLICA).filter_available(channel=channel).filter(
Q(variations__isnull=True) Q(variations__isnull=True)
& Q(quotas__pk=OuterRef('pk')) & Q(quotas__pk=OuterRef('pk'))
).order_by().values_list('quotas__pk').annotate( ).order_by().values_list('quotas__pk').annotate(
@@ -186,7 +187,7 @@ class EventMixin:
Prefetch( Prefetch(
'quotas', 'quotas',
to_attr='active_quotas', to_attr='active_quotas',
queryset=Quota.objects.annotate( queryset=Quota.objects.using(settings.DATABASE_REPLICA).annotate(
active_items=Subquery(sq_active_item, output_field=models.TextField()), active_items=Subquery(sq_active_item, output_field=models.TextField()),
active_variations=Subquery(sq_active_variation, output_field=models.TextField()), active_variations=Subquery(sq_active_variation, output_field=models.TextField()),
).exclude( ).exclude(
@@ -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:
@@ -445,6 +470,7 @@ class Event(EventMixin, LoggedModel):
self.plugins = other.plugins self.plugins = other.plugins
self.is_public = other.is_public self.is_public = other.is_public
self.testmode = other.testmode
self.save() self.save()
tax_map = {} tax_map = {}
@@ -492,6 +518,11 @@ class Event(EventMixin, LoggedModel):
vars = list(q.variations.all()) vars = list(q.variations.all())
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:
@@ -527,6 +558,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
@@ -666,8 +715,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):
@@ -870,10 +923,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")
@@ -882,6 +939,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
@@ -940,6 +1019,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)
+3
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))
+104 -27
View File
@@ -16,11 +16,15 @@ 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 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_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
@@ -154,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):
@@ -225,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,
@@ -292,6 +311,11 @@ class Item(LoggedModel):
verbose_name=_("Generate tickets"), verbose_name=_("Generate tickets"),
blank=True, null=True, blank=True, null=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
) )
@@ -327,12 +351,14 @@ 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'),
default=False, default=False,
help_text=_('If this option is set, the product will only be sold as part of bundle products.') help_text=_('If this option is set, the product will only be sold as part of bundle products. Do '
'<strong>not</strong> check this option if you want to use this product as an add-on product, '
'but only for fixed bundles!')
) )
allow_cancel = models.BooleanField( allow_cancel = models.BooleanField(
verbose_name=_('Allow product to be canceled'), verbose_name=_('Allow product to be canceled'),
@@ -388,10 +414,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
@@ -588,6 +621,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")
@@ -624,6 +659,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()
@@ -893,12 +929,14 @@ class Question(LoggedModel):
:param items: A set of ``Items`` objects that this question should be applied to :param items: A set of ``Items`` objects that this question should be applied to
:param ask_during_checkin: Whether to ask this question during check-in instead of during check-out. :param ask_during_checkin: Whether to ask this question during check-in instead of during check-out.
:type ask_during_checkin: bool :type ask_during_checkin: bool
:param hidden: Whether to only show the question in the backend
:type hidden: bool
:param identifier: An arbitrary, internal identifier :param identifier: An arbitrary, internal identifier
: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"
@@ -910,6 +948,7 @@ class Question(LoggedModel):
TYPE_DATE = "D" TYPE_DATE = "D"
TYPE_TIME = "H" TYPE_TIME = "H"
TYPE_DATETIME = "W" TYPE_DATETIME = "W"
TYPE_COUNTRYCODE = "CC"
TYPE_CHOICES = ( TYPE_CHOICES = (
(TYPE_NUMBER, _("Number")), (TYPE_NUMBER, _("Number")),
(TYPE_STRING, _("Text (one line)")), (TYPE_STRING, _("Text (one line)")),
@@ -921,6 +960,7 @@ class Question(LoggedModel):
(TYPE_DATE, _("Date")), (TYPE_DATE, _("Date")),
(TYPE_TIME, _("Time")), (TYPE_TIME, _("Time")),
(TYPE_DATETIME, _("Date and time")), (TYPE_DATETIME, _("Date and time")),
(TYPE_COUNTRYCODE, _("Country code (ISO 3166-1 alpha-2)")),
) )
event = models.ForeignKey( event = models.ForeignKey(
@@ -968,10 +1008,17 @@ class Question(LoggedModel):
'pretixdesk 0.2 or newer.'), 'pretixdesk 0.2 or newer.'),
default=False default=False
) )
hidden = models.BooleanField(
verbose_name=_('Hidden question'),
help_text=_('This question will only show up in the backend.'),
default=False
)
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")
@@ -1071,6 +1118,12 @@ class Question(LoggedModel):
return dt return dt
except: except:
raise ValidationError(_('Invalid datetime input.')) raise ValidationError(_('Invalid datetime input.'))
elif self.type == Question.TYPE_COUNTRYCODE and answer:
c = Country(answer.upper())
if c.name:
return answer
else:
raise ValidationError(_('Unknown country code.'))
return answer return answer
@@ -1216,6 +1269,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")
@@ -1225,6 +1289,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()
@@ -1274,6 +1339,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 (
@@ -1290,7 +1363,8 @@ class Quota(LoggedModel):
'cached_availability_state', 'cached_availability_number', 'cached_availability_time', 'cached_availability_state', 'cached_availability_number', 'cached_availability_time',
'cached_availability_paid_orders' 'cached_availability_paid_orders'
], ],
clear_cache=False clear_cache=False,
using='default'
) )
if _cache is not None: if _cache is not None:
@@ -1298,8 +1372,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
+230 -68
View File
@@ -1,4 +1,5 @@
import copy import copy
import hashlib
import json import json
import logging import logging
import os import os
@@ -14,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
@@ -24,7 +25,8 @@ 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 pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import 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
@@ -180,6 +182,12 @@ class Order(LockModel, LoggedModel):
default=False default=False
) )
sales_channel = models.CharField(max_length=190, default="web") sales_channel = models.CharField(max_length=190, default="web")
email_known_to_work = models.BooleanField(
default=False,
verbose_name=_('E-mail address verified')
)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Order") verbose_name = _("Order")
@@ -190,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(
@@ -198,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()
@@ -206,6 +222,9 @@ class Order(LockModel, LoggedModel):
self.event.cache.delete('complain_testmode_orders') self.event.cache.delete('complain_testmode_orders')
self.delete() self.delete()
def email_confirm_hash(self):
return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
@property @property
def fees(self): def fees(self):
""" """
@@ -215,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
@@ -238,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)
@@ -249,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:
@@ -423,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.
@@ -607,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."),
@@ -615,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(
@@ -665,7 +696,7 @@ class Order(LockModel, LoggedModel):
def send_mail(self, subject: str, template: Union[str, LazyI18nString], def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent', context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None, user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False): auth=None, attach_tickets=False, position: 'OrderPosition'=None):
""" """
Sends an email to the user that placed this order. Basically, this method does two things: Sends an email to the user that placed this order. Basically, this method does two things:
@@ -682,6 +713,9 @@ class Order(LockModel, LoggedModel):
:param headers: Dictionary with additional mail headers :param headers: Dictionary with additional mail headers
:param sender: Custom email sender. :param sender: Custom email sender.
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download :param attach_tickets: Attach tickets of this order, if they are existing and ready to download
:param position: An order position this refers to. If given, no invoices will be attached, the tickets will
only be attached for this position and child positions, the link will only point to the
position and the attendee email will be used if available.
""" """
from pretix.base.services.mail import SendMailException, mail, render_mail from pretix.base.services.mail import SendMailException, mail, render_mail
@@ -693,12 +727,16 @@ class Order(LockModel, LoggedModel):
with language(self.locale): with language(self.locale):
recipient = self.email recipient = self.email
if position and position.attendee_email:
recipient = position.attendee_email
try: try:
email_content = render_mail(template, context) email_content = render_mail(template, context)
mail( mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.locale, self, headers, sender, self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets invoices=invoices, attach_tickets=attach_tickets,
position=position
) )
except SendMailException: except SendMailException:
raise raise
@@ -710,6 +748,7 @@ class Order(LockModel, LoggedModel):
data={ data={
'subject': subject, 'subject': subject,
'message': email_content, 'message': email_content,
'position': position.positionid if position else None,
'recipient': recipient, 'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [], 'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets, 'attach_tickets': attach_tickets,
@@ -729,9 +768,10 @@ class Order(LockModel, LoggedModel):
email_template = self.event.settings.mail_text_resend_link email_template = self.event.settings.mail_text_resend_link
email_context = { email_context = {
'event': self.event.name, 'event': self.event.name,
'url': build_absolute_uri(self.event, 'presale:event.order', kwargs={ 'url': build_absolute_uri(self.event, 'presale:event.order.open', kwargs={
'order': self.code, 'order': self.code,
'secret': self.secret 'secret': self.secret,
'hash': self.email_confirm_hash()
}), }),
'invoice_name': invoice_name, 'invoice_name': invoice_name,
'invoice_company': invoice_company, 'invoice_company': invoice_company,
@@ -797,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:
@@ -828,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]
@@ -860,6 +906,8 @@ class QuestionAnswer(models.Model):
return date_format(d, "TIME_FORMAT") return date_format(d, "TIME_FORMAT")
except ValueError: except ValueError:
return self.answer return self.answer
elif self.question.type == Question.TYPE_COUNTRYCODE and self.answer:
return Country(self.answer).name or self.answer
else: else:
return self.answer return self.answer
@@ -902,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,
@@ -948,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
@@ -959,6 +1012,10 @@ class AbstractPosition(models.Model):
else: else:
return {} return {}
@meta_info_data.setter
def meta_info_data(self, d):
self.meta_info = json.dumps(d)
def cache_answers(self, all=True): def cache_answers(self, all=True):
""" """
Creates two properties on the object. Creates two properties on the object.
@@ -976,7 +1033,8 @@ class AbstractPosition(models.Model):
if hasattr(self.item, 'questions_to_ask'): if hasattr(self.item, 'questions_to_ask'):
questions = list(copy.copy(q) for q in self.item.questions_to_ask) questions = list(copy.copy(q) for q in self.item.questions_to_ask)
else: else:
questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False)) questions = list(copy.copy(q) for q in self.item.questions.filter(ask_during_checkin=False,
hidden=False))
else: else:
questions = list(copy.copy(q) for q in self.item.questions.all()) questions = list(copy.copy(q) for q in self.item.questions.all())
@@ -984,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:
@@ -1004,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
@@ -1113,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',)
@@ -1129,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):
@@ -1140,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)
@@ -1160,7 +1219,8 @@ class OrderPayment(models.Model):
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth) self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order) order_paid.send(self.order.event, order=self.order)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='', ignore_date=False, lock=True): def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text='',
ignore_date=False, lock=True, payment_date=None):
""" """
Marks the payment as complete. If possible, this also marks the order as paid if no further Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required payment is required
@@ -1181,8 +1241,6 @@ class OrderPayment(models.Model):
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False`` :raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
""" """
from pretix.base.services.invoices import generate_invoice, invoice_qualified from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with transaction.atomic(): with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk) locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
@@ -1191,7 +1249,7 @@ class OrderPayment(models.Model):
return return
locked_instance.state = self.PAYMENT_STATE_CONFIRMED locked_instance.state = self.PAYMENT_STATE_CONFIRMED
locked_instance.payment_date = now() locked_instance.payment_date = payment_date or now()
locked_instance.info = self.info # required for backwards compatibility locked_instance.info = self.info # required for backwards compatibility
locked_instance.save(update_fields=['state', 'payment_date', 'info']) locked_instance.save(update_fields=['state', 'payment_date', 'info'])
@@ -1246,35 +1304,77 @@ class OrderPayment(models.Model):
) )
if send_mail: if send_mail:
with language(self.order.locale): self._send_paid_mail(invoice, user, mail_text)
try: if self.order.event.settings.mail_send_order_paid_attendee:
invoice_name = self.order.invoice_address.name for p in self.order.positions.all():
invoice_company = self.order.invoice_address.company if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
except InvoiceAddress.DoesNotExist: self._send_paid_mail_attendee(p, user)
invoice_name = ""
invoice_company = "" def _send_paid_mail_attendee(self, position, user):
email_template = self.order.event.settings.mail_text_order_paid from pretix.base.services.mail import SendMailException
email_context = { from pretix.multidomain.urlreverse import build_absolute_uri
'event': self.order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={ with language(self.order.locale):
'order': self.order.code, name_scheme = PERSON_NAME_SCHEMES[self.order.event.settings.name_scheme]
'secret': self.order.secret email_template = self.order.event.settings.mail_text_order_paid_attendee
}), email_context = {
'downloads': self.order.event.settings.get('ticket_download', as_type=bool), 'event': self.order.event.name,
'invoice_name': invoice_name, 'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
'invoice_company': invoice_company, 'url': build_absolute_uri(self.order.event, 'presale:event.order.position', kwargs={
'payment_info': mail_text 'order': self.order.code,
} 'secret': position.web_secret,
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code} 'position': position.positionid
try: }),
self.order.send_mail( 'attendee_name': position.attendee_name,
email_subject, email_template, email_context, }
'pretix.event.order.email.order_paid', user, for f, l, w in name_scheme['fields']:
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [], email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
attach_tickets=True
) email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
except SendMailException: try:
logger.exception('Order paid email could not be sent') self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[], position=position,
attach_tickets=True
)
except SendMailException:
logger.exception('Order paid email could not be sent')
def _send_paid_mail(self, invoice, user, mail_text):
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.order.locale):
try:
invoice_name = self.order.invoice_address.name
invoice_company = self.order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = self.order.event.settings.mail_text_order_paid
email_context = {
'event': self.order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={
'order': self.order.code,
'secret': self.order.secret,
'hash': self.order.email_confirm_hash()
}),
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
'payment_info': mail_text
}
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
try:
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order paid email could not be sent')
@property @property
def refunded_amount(self): def refunded_amount(self):
@@ -1428,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',)
@@ -1444,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):
@@ -1489,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)
@@ -1566,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
@@ -1663,6 +1765,7 @@ class OrderPosition(AbstractPosition):
verbose_name=_('Tax value') verbose_name=_('Tax value')
) )
secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True) secret = models.CharField(max_length=64, default=generate_position_secret, db_index=True)
web_secret = models.CharField(max_length=32, default=generate_secret, db_index=True)
pseudonymization_id = models.CharField( pseudonymization_id = models.CharField(
max_length=16, max_length=16,
unique=True, unique=True,
@@ -1670,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:
@@ -1770,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
@@ -1786,6 +1890,60 @@ class OrderPosition(AbstractPosition):
def event(self): def event(self):
return self.order.event return self.order.event
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
* Call ``pretix.base.services.mail.mail`` with useful values for the ``event``, ``locale``, ``recipient`` and
``order`` parameters.
* Create a ``LogEntry`` with the email contents.
:param subject: Subject of the email
:param template: LazyI18nString or template filename, see ``pretix.base.services.mail.mail`` for more details
:param context: Dictionary to use for rendering the template
:param log_entry_type: Key to be used for the log entry
:param user: Administrative user who triggered this mail to be sent
:param headers: Dictionary with additional mail headers
:param sender: Custom email sender.
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
"""
from pretix.base.services.mail import SendMailException, mail, render_mail
if not self.email:
return
for k, v in self.event.meta_data.items():
context['meta_' + k] = v
with language(self.locale):
recipient = self.email
try:
email_content = render_mail(template, context)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers, sender,
invoices=invoices, attach_tickets=attach_tickets
)
except SendMailException:
raise
else:
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
}
)
class CartPosition(AbstractPosition): class CartPosition(AbstractPosition):
""" """
@@ -1823,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")
@@ -1872,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()
+111
View File
@@ -0,0 +1,111 @@
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 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')
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'],
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)
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):
return self.name
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()
+29 -4
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
+3
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")
+6 -6
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 ""
+6 -1
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,11 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT" "TIME_FORMAT"
) if ev.date_admission else "" ) if ev.date_admission else ""
}), }),
("seat", {
"label": _("Seat name"),
"editor_sample": _("3, 4-5"),
"evaluate": lambda op, order, ev: str(op.seat if op.seat else _('General admission'))
}),
)) ))
+22 -1
View File
@@ -1,8 +1,10 @@
import sys
from enum import Enum from enum import Enum
from typing import List from typing import List
from django.apps import apps from django.apps import AppConfig, apps
from django.conf import settings from django.conf import settings
from django.core.exceptions import ImproperlyConfigured
class PluginType(Enum): class PluginType(Enum):
@@ -39,3 +41,22 @@ def get_all_plugins(event=None) -> List[type]:
plugins, plugins,
key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', '')) key=lambda m: (0 if m.module.startswith('pretix.') else 1, str(m.name).lower().replace('pretix ', ''))
) )
class PluginConfig(AppConfig):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not hasattr(self, 'PretixPluginMeta'):
raise ImproperlyConfigured("A pretix plugin config should have a PretixPluginMeta inner class.")
if hasattr(self.PretixPluginMeta, 'compatibility'):
import pkg_resources
try:
pkg_resources.require(self.PretixPluginMeta.compatibility)
except pkg_resources.VersionConflict as e:
print("Incompatible plugins found!")
print("Plugin {} requires you to have {}, but you installed {}.".format(
self.name, e.req, e.dist
))
sys.exit(1)
+1 -1
View File
@@ -71,7 +71,7 @@ class RelativeDateWrapper:
else: else:
base_date = getattr(event, self.data.base_date_name) or event.date_from base_date = getattr(event, self.data.base_date_name) or event.date_from
oldoffset = base_date.utcoffset() oldoffset = base_date.astimezone(tz).utcoffset()
new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before) new_date = base_date.astimezone(tz) - datetime.timedelta(days=self.data.days_before)
if self.data.time: if self.data.time:
new_date = new_date.replace( new_date = new_date.replace(
+116 -40
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():
@@ -634,7 +695,7 @@ class CartManager:
Q(voucher=voucher) & Q(event=self.event) & Q(voucher=voucher) & Q(event=self.event) &
Q(expires__gte=self.now_dt) Q(expires__gte=self.now_dt)
).exclude(pk__in=[ ).exclude(pk__in=[
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation) op.position.id for op in self._operations if isinstance(op, self.ExtendOperation)
]) ])
cart_count = redeemed_in_carts.count() cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count v_avail = voucher.max_usages - voucher.redeemed - cart_count
@@ -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))
@@ -744,12 +811,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 +859,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 +894,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 +948,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 +979,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 +1013,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 +1022,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 +1033,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 +1052,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 +1062,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:
+3 -3
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()}
+4
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()
+15 -6
View File
@@ -2,24 +2,33 @@ from typing import Any, Dict
from django.core.files.base import ContentFile from django.core.files.base import ContentFile
from django.utils.timezone import override from django.utils.timezone import override
from django.utils.translation import ugettext
from pretix.base.i18n import 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
@app.task(base=ProfiledTask) class ExportError(LazyLocaleException):
def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None: pass
event = Event.objects.get(id=event)
@app.task(base=ProfiledEventTask, throws=(ExportError,))
def export(event: Event, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
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)
for receiver, response in responses: for receiver, response in responses:
ex = response(event) ex = response(event)
if ex.identifier == provider: if ex.identifier == provider:
file.filename, file.type, data = ex.render(form_data) d = ex.render(form_data)
if d is None:
raise ExportError(
ugettext('Your export did not contain any data.')
)
file.filename, file.type, data = d
file.file.save(cachedfile_name(file, file.filename), ContentFile(data)) file.file.save(cachedfile_name(file, file.filename), ContentFile(data))
file.save() file.save()
return file.pk return file.pk
+20 -12
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,20 +249,23 @@ 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):
if order.total == Decimal('0.00') or order.require_approval: if order.total == Decimal('0.00') or order.require_approval or \
order.sales_channel not in order.event.settings.get('invoice_generate_sales_channels'):
return False return False
return True return True
+135 -90
View File
@@ -1,5 +1,7 @@
import inspect
import logging import logging
import smtplib import smtplib
import warnings
from email.utils import formataddr from email.utils import formataddr
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
@@ -9,11 +11,14 @@ 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
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Event, Invoice, InvoiceAddress, Order from pretix.base.models import (
Event, Invoice, InvoiceAddress, Order, OrderPosition,
)
from pretix.base.services.invoices import invoice_pdf_task from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tasks import TransactionAwareTask from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order from pretix.base.services.tickets import get_tickets_for_order
@@ -38,8 +43,8 @@ class SendMailException(Exception):
def mail(email: str, subject: str, template: Union[str, LazyI18nString], def mail(email: str, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, event: Event=None, locale: str=None, context: Dict[str, Any]=None, event: Event=None, locale: str=None,
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None, order: Order=None, position: OrderPosition=None, headers: dict=None, sender: str=None,
attach_tickets=False): invoices: list=None, attach_tickets=False):
""" """
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation. Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -60,6 +65,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
:param order: The order this email is related to (optional). If set, this will be used to include a link to the :param order: The order this email is related to (optional). If set, this will be used to include a link to the
order below the email. order below the email.
:param order: The order position this email is related to (optional). If set, this will be used to include a link
to the order position instead of the order below the email.
:param headers: A dict of custom mail headers to add to the mail :param headers: A dict of custom mail headers to add to the mail
:param locale: The locale to be used while evaluating the subject and the template :param locale: The locale to be used while evaluating the subject and the template
@@ -100,7 +108,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
subject = str(subject).format_map(context) subject = str(subject).format_map(context)
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM) sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
if event: if event:
sender = formataddr((str(event.name), sender)) sender_name = event.settings.mail_from_name or str(event.name)
sender = formataddr((sender_name, sender))
else: else:
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender)) sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
@@ -111,7 +120,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
if event: if event:
renderer = event.get_html_mail_renderer() renderer = event.get_html_mail_renderer()
if event.settings.mail_bcc: if event.settings.mail_bcc:
bcc.append(event.settings.mail_bcc) for bcc_mail in event.settings.mail_bcc.split(','):
bcc.append(bcc_mail.strip())
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'): if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = event.settings.contact_mail headers['Reply-To'] = event.settings.contact_mail
@@ -130,9 +140,26 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
body_plain += signature body_plain += signature
body_plain += "\r\n\r\n-- \r\n" body_plain += "\r\n\r\n-- \r\n"
if order: if order and order.testmode:
if order.testmode: subject = "[TESTMODE] " + subject
subject = "[TESTMODE] " + subject
if order and position:
body_plain += _(
"You are receiving this email because someone placed an order for {event} for you."
).format(event=event.name)
body_plain += "\r\n"
body_plain += _(
"You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri(
order.event, 'presale:event.order.position', kwargs={
'order': order.code,
'secret': position.web_secret,
'position': position.positionid,
}
)
)
elif order:
body_plain += _( body_plain += _(
"You are receiving this email because you placed an order for {event}." "You are receiving this email because you placed an order for {event}."
).format(event=event.name) ).format(event=event.name)
@@ -141,16 +168,24 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
"You can view your order details at the following URL:\n{orderurl}." "You can view your order details at the following URL:\n{orderurl}."
).replace("\n", "\r\n").format( ).replace("\n", "\r\n").format(
event=event.name, orderurl=build_absolute_uri( event=event.name, orderurl=build_absolute_uri(
order.event, 'presale:event.order', kwargs={ order.event, 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret 'secret': order.secret,
'hash': order.email_confirm_hash()
} }
) )
) )
body_plain += "\r\n" body_plain += "\r\n"
try: try:
body_html = renderer.render(content_plain, signature, str(subject), order) if 'position' in inspect.signature(renderer.render).parameters:
body_html = renderer.render(content_plain, signature, str(subject), order, position)
else:
# Backwards compatibility
warnings.warn('E-mail renderer called without position argument because position argument is not '
'supported.',
DeprecationWarning)
body_html = renderer.render(content_plain, signature, str(subject), order)
except: except:
logger.exception('Could not render HTML body') logger.exception('Could not render HTML body')
body_html = None body_html = None
@@ -164,8 +199,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
sender=sender, sender=sender,
event=event.id if event else None, event=event.id if event else None,
headers=headers, headers=headers,
invoices=[i.pk for i in invoices] if invoices else [], invoices=[i.pk for i in invoices] if invoices and not position else [],
order=order.pk if order else None, order=order.pk if order else None,
position=position.pk if position else None,
attach_tickets=attach_tickets attach_tickets=attach_tickets
) )
@@ -180,98 +216,107 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
@app.task(base=TransactionAwareTask, bind=True) @app.task(base=TransactionAwareTask, bind=True)
def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str, def mail_send_task(self, *args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None, event: int=None, position: int=None, headers: dict=None, bcc: List[str]=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") email.attach_alternative(html, "text/html")
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
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 try:
else: email.attach(
if attach_tickets: '{}.pdf'.format(inv.number),
args = [] inv.file.file.read(),
attach_size = 0 'application/pdf'
for name, ct in get_tickets_for_order(order):
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):
+22 -11
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):
@@ -104,7 +111,11 @@ def send_notification_mail(notification: Notification, user: User):
mail_send_task.apply_async(kwargs={ mail_send_task.apply_async(kwargs={
'to': [user.email], 'to': [user.email],
'subject': '[{}] {}'.format(settings.PRETIX_INSTANCE_NAME, notification.title), 'subject': '[{}] {}: {}'.format(
settings.PRETIX_INSTANCE_NAME,
notification.event.settings.mail_prefix or notification.event.slug.upper(),
notification.title
),
'body': body_plain, 'body': body_plain,
'html': body_html, 'html': body_html,
'sender': settings.MAIL_FROM, 'sender': settings.MAIL_FROM,
+285 -105
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,11 +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.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, 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
@@ -80,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__)
@@ -222,9 +227,10 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires), 'date': LazyDate(order.expires),
'event': order.event.name, 'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret 'secret': order.secret,
'hash': order.email_confirm_hash()
}), }),
'invoice_name': invoice_name, 'invoice_name': invoice_name,
'invoice_company': invoice_company, 'invoice_company': invoice_company,
@@ -282,9 +288,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency), 'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires), 'date': LazyDate(order.expires),
'event': order.event.name, 'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret 'secret': order.secret,
'hash': order.email_confirm_hash()
}), }),
'comment': comment, 'comment': comment,
'invoice_name': invoice_name, 'invoice_name': invoice_name,
@@ -375,9 +382,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
email_context = { email_context = {
'event': order.event.name, 'event': order.event.name,
'code': order.code, 'code': order.code,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={ 'url': build_absolute_uri(order.event, 'presale:event.order.open', kwargs={
'order': order.code, 'order': order.code,
'secret': order.secret 'secret': order.secret,
'hash': order.email_confirm_hash()
}) })
} }
with language(order.locale): with language(order.locale):
@@ -423,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
@@ -485,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
@@ -531,7 +555,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
continue continue
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross): if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
positions[i] = cp
cp.price = price.gross cp.price = price.gross
cp.includes_tax = bool(price.rate) cp.includes_tax = bool(price.rate)
cp.save() cp.save()
@@ -555,7 +578,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break break
if quota_ok: if quota_ok:
positions[i] = cp
cp.expires = now_dt + timedelta( cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int)) minutes=event.settings.get('reservation_time', as_type=int))
cp.save() cp.save()
@@ -570,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:
@@ -580,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
@@ -644,10 +670,80 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
return order, p return order, p
def _perform_order(event: str, payment_provider: str, position_ids: List[str], def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'): invoice, payment: OrderPayment):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
event = Event.objects.get(id=event) if pprov:
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:
payment_info = None
email_context = {
'total': LazyNumber(order.total),
'currency': event.currency,
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
'date': LazyDate(order.expires),
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'payment_info': payment_info,
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order received email could not be sent')
def _order_placed_email_attendee(event: Event, order: Order, position: OrderPosition, email_template, log_entry: str):
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
email_context = {
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order.position', kwargs={
'order': order.code,
'secret': position.web_secret,
'position': position.positionid
}),
'attendee_name': position.attendee_name,
}
for f, l, w in name_scheme['fields']:
email_context['attendee_name_%s' % f] = position.attendee_name_parts.get(f, '')
email_subject = _('Your event registration: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[],
attach_tickets=True,
position=position
)
except SendMailException:
logger.exception('Order received email could not be sent to attendee')
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'):
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:
@@ -661,22 +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,
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):
@@ -705,54 +814,32 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if order.require_approval: if order.require_approval:
email_template = event.settings.mail_text_order_placed_require_approval email_template = event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval' log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_order_flow: elif free_order_flow:
email_template = event.settings.mail_text_order_free email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free' log_entry = 'pretix.event.order.email.order_free'
email_attendees = event.settings.mail_send_order_free_attendee
email_attendees_template = event.settings.mail_text_order_free_attendee
else: else:
email_template = event.settings.mail_text_order_placed email_template = event.settings.mail_text_order_placed
log_entry = 'pretix.event.order.email.order_placed' log_entry = 'pretix.event.order.email.order_placed'
try: email_attendees = event.settings.mail_send_order_placed_attendee
invoice_name = order.invoice_address.name email_attendees_template = event.settings.mail_text_order_placed_attendee
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
if pprov: _order_placed_email(event, order, pprov, email_template, log_entry, invoice, payment)
payment_info = str(pprov.order_pending_mail_render(order)) if email_attendees:
else: for p in order.positions.all():
payment_info = None if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
_order_placed_email_attendee(event, order, p, email_attendees_template, log_entry)
email_context = {
'total': LazyNumber(order.total),
'currency': event.currency,
'total_with_currency': LazyCurrencyNumber(order.total, event.currency),
'date': LazyDate(order.expires),
'event': event.name,
'url': build_absolute_uri(event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'payment_info': payment_info,
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order received email could not be sent')
return order.id return order.id
@receiver(signal=periodic_task) @receiver(signal=periodic_task)
@scopes_disabled()
def expire_orders(sender, **kwargs): def expire_orders(sender, **kwargs):
eventcache = {} eventcache = {}
@@ -767,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)
@@ -800,9 +888,10 @@ def send_expiry_warnings(sender, **kwargs):
email_template = eventsettings.mail_text_order_expire_warning email_template = eventsettings.mail_text_order_expire_warning
email_context = { email_context = {
'event': o.event.name, 'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={ 'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={
'order': o.code, 'order': o.code,
'secret': o.secret 'secret': o.secret,
'hash': o.email_confirm_hash()
}), }),
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'), 'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
'invoice_name': invoice_name, 'invoice_name': invoice_name,
@@ -823,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)
@@ -851,9 +941,10 @@ def send_download_reminders(sender, **kwargs):
email_template = e.settings.mail_text_download_reminder email_template = e.settings.mail_text_download_reminder
email_context = { email_context = {
'event': o.event.name, 'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={ 'url': build_absolute_uri(o.event, 'presale:event.order.open', kwargs={
'order': o.code, 'order': o.code,
'secret': o.secret 'secret': o.secret,
'hash': o.email_confirm_hash()
}), }),
} }
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code} email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
@@ -866,6 +957,60 @@ def send_download_reminders(sender, **kwargs):
except SendMailException: except SendMailException:
logger.exception('Reminder email could not be sent') logger.exception('Reminder email could not be sent')
if e.settings.mail_send_download_reminder_attendee:
name_scheme = PERSON_NAME_SCHEMES[e.settings.name_scheme]
for p in o.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != o.email:
email_template = e.settings.mail_text_download_reminder_attendee
email_context = {
'event': e.name,
'url': build_absolute_uri(e, 'presale:event.order.position', kwargs={
'order': o.code,
'secret': p.web_secret,
'position': p.positionid
}),
'attendee_name': p.attendee_name,
}
for f, l, w in name_scheme['fields']:
email_context['attendee_name_%s' % f] = p.attendee_name_parts.get(f, '')
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True, position=p
)
except SendMailException:
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 = {
@@ -880,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',))
@@ -898,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
@@ -915,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)
@@ -970,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:
@@ -994,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:
@@ -1004,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'):
@@ -1012,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:
@@ -1078,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:
@@ -1098,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,
@@ -1151,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={
@@ -1162,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)
@@ -1212,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:
@@ -1227,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:
@@ -1254,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
@@ -1339,33 +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', kwargs={
'order': order.code,
'secret': order.secret
}),
'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
@@ -1385,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()
@@ -1395,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)
@@ -1418,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):
@@ -1434,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:
+25 -17
View File
@@ -1,11 +1,12 @@
from datetime import timedelta from datetime import timedelta
from django.db import models from django.conf import settings
from django.db.models import F, Max, OuterRef, Q, Subquery 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 LogEntry, Quota from pretix.base.models import Event, LogEntry
from pretix.celery_app import app from pretix.celery_app import app
from ..signals import periodic_task from ..signals import periodic_task
@@ -17,20 +18,27 @@ def build_all_quota_caches(sender, **kwargs):
@app.task @app.task
@scopes_disabled()
def refresh_quota_caches(): def refresh_quota_caches():
last_activity = LogEntry.objects.filter( # Active events
event=OuterRef('event_id'), active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter(
datetime__gt=now() - timedelta(days=7)
).order_by().values('event').annotate( ).order_by().values('event').annotate(
m=Max('datetime') last_activity=Max('datetime')
).values(
'm'
) )
quotas = Quota.objects.annotate( for a in active:
last_activity=Subquery(last_activity, output_field=models.DateTimeField()) try:
).filter( e = Event.objects.using(settings.DATABASE_REPLICA).get(pk=a['event'])
Q(cached_availability_time__isnull=True) | except Event.DoesNotExist:
Q(cached_availability_time__lt=F('last_activity')) | continue
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7)) quotas = e.quotas.filter(
).select_related('subevent') Q(cached_availability_time__isnull=True) |
for q in quotas: Q(cached_availability_time__lt=a['last_activity']) |
q.availability() Q(cached_availability_time__lt=now() - timedelta(hours=2))
).filter(
Q(subevent__isnull=True) |
Q(subevent__date_to__isnull=False, subevent__date_to__gte=now() - timedelta(days=14)) |
Q(subevent__date_from__gte=now() - timedelta(days=14))
)
for q in quotas:
q.availability()
+57
View File
@@ -0,0 +1,57 @@
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)
}
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)
if seat.product != p:
seat.product = p
seat.save()
else:
create_seats.append(Seat(
event=event,
subevent=subevent,
seat_guid=ss.guid,
name=ss.name,
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()
+5 -7
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)
+55 -6
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()

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