Compare commits

...
Author SHA1 Message Date
Raphael Michel 8bbe605439 Fix voucher bulk form 2019-07-07 12:42:38 +02:00
Raphael Michel 8267da66e4 Add test 2019-07-07 12:25:08 +02:00
Raphael Michel 481db3cb88 Backwards-compatible implementation 2019-07-07 12:20:41 +02:00
Raphael Michel 435d90474a Changes in checks 2019-07-06 15:21:32 +02:00
Raphael Michel fcc4170a4a Add shell_scoped command. Thanks @rixx! 2019-06-27 11:39:12 +02:00
Raphael Michel c7f345e98e Allow to filter order list by variations 2019-06-26 14:27:02 +02:00
Raphael Michel d30fbf4e6a Event front page: Show calendar by default when a month is selected 2019-06-25 13:02:38 +02:00
Raphael Michel 5326aa7486 Merge pull request #1325 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-06-25 11:33:19 +02:00
Raphael Michel 53147c0f0c Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3152 of 3152 strings)

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

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

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

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

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

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

* Add seat guids

* Add product_list_top

* CartAdd: Ignore item when a seat is passed

* Cart display

* product_list_top → render_seating_plan

* Render seating plan in voucher redemption

* Fix failing tests

* Add tests for extending cart positions with seats

* Add subevent_forms to docs

* Update schema, migrations

* Dealing with expired orders

* steps to order change

* Change order positions

* Allow to add seats

* tests for ocm

* Fix things after rebase

* Seating plans API

* Add more tests for cart behaviour

* Widget support

* Adjust widget tests

* Re-enable CSP

* Update schema

* Api: position.seat

* Add guid to word list

* API: (sub)event.seating_plan

* Vali fixes

* Fix api

* Fix reference in test

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

* Changes after code-review

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix tests.api

* Update tasks and cronjobs

* Fix remaining tests

* Remove unused import

* Fix tests after rebase

* Disable scopes for get_Events_with_any_permission

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

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

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

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

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

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

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

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-06-06 11:11:46 +02:00
Raphael Michel dd4075b2cc Clarify UX around subevent selection 2019-06-06 11:10:51 +02:00
Raphael Michel b549cb451a Fix invalid signature 2019-06-05 16:44:49 +02:00
Raphael Michel 576132b2d0 Bump to 2.9.0.dev0 2019-06-05 16:28:49 +02:00
Raphael Michel e0c432d014 [SECURITY] Do not allow to enumerate organizers 2019-06-05 16:27:21 +02:00
Raphael Michel b66a35df7a Bump to 2.8.0 2019-06-05 16:13:37 +02:00
Raphael Michel 2e1347cf9a Update from Weblate (#1311)
Update from Weblate
2019-06-05 13:39:11 +02:00
Raphael Michel 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 Michel 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 Michel f1e097c1b1 Update from Weblate (#1309)
Update from Weblate
2019-06-05 12:20:14 +02:00
ThanosTeste 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
ThanosTeste 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
ThanosTeste 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
ThanosTeste 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 Michel 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
ThanosTeste 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
ThanosTeste 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
mapostolopoulou 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
ThanosTeste 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
markiousi 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 Michel 27c3e5d875 Update from Weblate (#1305)
Update from Weblate
2019-05-29 15:57:18 +02:00
ThanosTeste 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
mapostolopoulou 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
ThanosTeste 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
mapostolopoulou 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
ThanosTeste 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
mapostolopoulou 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
ThanosTeste 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
ThanosTeste 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
ThanosTeste 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
markiousi 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
ThanosTeste 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-Giannis 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
ThanosTeste 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
mapostolopoulou 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
markiousi 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
ThanosTeste 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
mapostolopoulou 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
markiousi 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
mapostolopoulou 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
ThanosTeste 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
markiousi 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
mapostolopoulou 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
ThanosTeste 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-Giannis 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
ThanosTeste 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
mapostolopoulou 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
markiousi 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
markiousi 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-Giannis 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
markiousi 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
ThanosTeste 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
mapostolopoulou 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
markiousi 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
mapostolopoulou 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
ThanosTeste 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
mapostolopoulou 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
ThanosTeste 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
markiousi 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
mapostolopoulou 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
mapostolopoulou 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
GiorgosPap 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
markiousi 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
ThanosTeste 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
ThanosTeste 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
GiorgosPap 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
markiousi 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
GiorgosPap 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
markiousi 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
ThanosTeste 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
mapostolopoulou 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
mapostolopoulou 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
ThanosTeste 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
mapostolopoulou 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
ThanosTeste 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
markiousi 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
mapostolopoulou 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
ThanosTeste 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
mapostolopoulou 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 Spy 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
ThanosTeste 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 Spy 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
ThanosTeste 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 Spy 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
ThanosTeste 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 Spy 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
mapostolopoulou 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
ThanosTeste 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
mapostolopoulou 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 Spy 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
ThanosTeste 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
mapostolopoulou 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 Spy 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
ThanosTeste 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
mapostolopoulou 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 Spy 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
ThanosTeste 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 Spy 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
ThanosTeste 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 Spy 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
ThanosTeste 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
mapostolopoulou 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
markiousi 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 Spy 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
mapostolopoulou 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
ThanosTeste 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
ThanosTeste 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
markiousi 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
markiousi 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 Spy 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 Spy 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-Giannis 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 Gross 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 Michel 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 Gross 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 Michel 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-Giannis 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
markiousi 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-Giannis 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
markiousi 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
markiousi 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-Giannis 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 Spy 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 Gross 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
lislis 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 Michel 1bd3a63959 Update from Weblate (#1300)
Update from Weblate
2019-05-24 10:45:35 +02:00
Raphael Michel 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 Michel 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 Michel edd92ac34d Update from Weblate (#1297)
Update from Weblate
2019-05-24 09:42:04 +02:00
Raphael Michel 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 Michel 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 Michel 036a555374 Update from Weblate (#1295)
Update from Weblate
2019-05-19 13:50:18 +02:00
Raphael Michel 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 Michel 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
ligi 97fc226e07 Document development dependency libenchant1c2a (#1294) 2019-05-19 13:06:24 +02:00
Raphael Michel d73c98bff0 Document Debian dependency python-venv (#1293)
Add external dependency python-venv
2019-05-19 13:02:58 +02:00
Raphael Michel aa186f7a09 Update setup.rst 2019-05-19 13:02:05 +02:00
ligi 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 Michel 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 bot 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 Marusic 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 Marusic 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 Michel 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 Kunze 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
311 changed files with 70073 additions and 47774 deletions
-2
View File
@@ -25,8 +25,6 @@ matrix:
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.7
env: JOB=plugins
- python: 3.7
env: JOB=doc-spelling
- python: 3.7
+22
View File
@@ -78,6 +78,10 @@ Example::
Enables or disables nagging staff users for leaving comments on their sessions for auditability.
Defaults to ``off``.
``obligatory_2fa``
Enables or disables obligatory usage of Two-Factor Authentication for users of the pretix backend.
Defaults to ``False``
Locale settings
---------------
@@ -273,6 +277,24 @@ to speed up various operations::
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.
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
-----------------
+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.
+11
View File
@@ -36,12 +36,20 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field
├ 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
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
This resource has been added.
.. versionchanged:: 3.0
This ``seat`` attribute has been added.
Cart position endpoints
-----------------------
@@ -87,6 +95,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"seat": null,
"answers": []
}
]
@@ -132,6 +141,7 @@ Cart position endpoints
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"seat": null,
"answers": []
}
@@ -178,6 +188,7 @@ Cart position endpoints
* ``item``
* ``variation`` (optional)
* ``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_email`` (optional)
* ``subevent`` (optional)
+2
View File
@@ -396,6 +396,7 @@ Order position endpoints
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 1,
@@ -505,6 +506,7 @@ Order position endpoints
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 1,
+31 -2
View File
@@ -27,9 +27,13 @@ presale_end datetime The date at whi
location multi-lingual string The event location (or ``null``)
has_subevents boolean ``true`` if the event series feature is active for this
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
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.
.. 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
---------
@@ -95,6 +107,8 @@ Endpoints
"location": null,
"has_subevents": false,
"meta_data": {},
"seating_plan": null,
"seat_category_mapping": {},
"plugins": [
"pretix.plugins.banktransfer"
"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_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 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
:statuscode 200: no error
:statuscode 401: Authentication failure
@@ -153,6 +170,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer"
@@ -198,6 +217,8 @@ Endpoints
"is_public": false,
"presale_start": null,
"presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null,
"has_subevents": false,
"meta_data": {},
@@ -228,6 +249,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"plugins": [
@@ -246,7 +269,7 @@ Endpoints
.. 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',
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
their value will be copied from the existing event.
@@ -277,6 +300,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"has_subevents": false,
"meta_data": {},
"plugins": [
@@ -307,6 +332,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.stripe",
@@ -368,6 +395,8 @@ Endpoints
"presale_end": null,
"location": null,
"has_subevents": false,
"seating_plan": null,
"seat_category_mapping": {},
"meta_data": {},
"plugins": [
"pretix.plugins.banktransfer",
+2
View File
@@ -23,3 +23,5 @@ Resources and endpoints
waitinglist
carts
webhooks
seatingplans
billing_invoices
+14 -1
View File
@@ -176,6 +176,10 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field
├ 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
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,
this field is missing. It will be added only if you add the
``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.
.. versionchanged:: 3.0
The attribute ``seat`` has been added.
.. _order-payment-resource:
Order payment resource
@@ -328,6 +336,7 @@ List of all orders
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,
@@ -470,6 +479,7 @@ Fetching individual orders
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"list": 44,
@@ -737,7 +747,7 @@ Creating orders
then call the ``mark_paid`` API method.
* ``testmode`` (optional) – Defaults to ``false``
* ``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.
* ``email``
* ``locale``
@@ -771,6 +781,7 @@ Creating orders
* ``item``
* ``variation``
* ``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_email``
* ``secret`` (optional)
@@ -1287,6 +1298,7 @@ List of all order positions
"tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null,
"subevent": null,
"checkins": [
@@ -1389,6 +1401,7 @@ Fetching individual positions
"addon_to": null,
"subevent": null,
"pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [
{
"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 string ordering: Manually set the ordering of results. Valid fields to be used are ``slug`` and
``name``. Default: ``slug``.
:statuscode 200: no error
:statuscode 401: Authentication failure
+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: 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
+21 -1
View File
@@ -36,7 +36,11 @@ variation_price_overrides list of objects List of variati
the default price
├ variation integer The internal variation ID
└ 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
@@ -54,6 +58,10 @@ meta_data dict Values set for
The attribute ``is_public`` has been added.
.. versionchanged:: 3.0
The attributes ``seating_plan`` and ``seat_category_mapping`` have been added.
Endpoints
---------
@@ -93,6 +101,8 @@ Endpoints
"date_admission": null,
"presale_start": null,
"presale_end": null,
"seating_plan": null,
"seat_category_mapping": {},
"location": null,
"item_price_overrides": [
{
@@ -142,6 +152,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -172,6 +184,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -223,6 +237,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -287,6 +303,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"item": 2,
@@ -371,6 +389,8 @@ Endpoints
"presale_start": null,
"presale_end": null,
"location": null,
"seating_plan": null,
"seat_category_mapping": {},
"item_price_overrides": [
{
"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
comment string An internal comment on the voucher
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.
.. versionchanged:: 3.0
The attribute ``show_hidden_items`` has been added.
Endpoints
---------
+2 -2
View File
@@ -66,7 +66,7 @@ source_suffix = '.rst'
#source_encoding = 'utf-8-sig'
# The master toctree document.
master_doc = 'contents'
master_doc = 'index'
# General information about the project.
project = 'pretix'
@@ -234,7 +234,7 @@ latex_elements = {
# (source start file, target name, title,
# author, documentclass [howto, manual, or own class]).
latex_documents = [
('contents', 'pretix.tex', 'pretix Documentation',
('index', 'pretix.tex', 'pretix Documentation',
'Raphael Michel', 'manual'),
]
+9 -5
View File
@@ -12,7 +12,7 @@ Core
.. automodule:: pretix.base.signals
:members: periodic_task, event_live_issues, event_copy_data, email_filter, register_notification_types,
item_copy_data, register_sales_channels, register_global_settings
item_copy_data, register_sales_channels, register_global_settings, quota_availability
Order events
""""""""""""
@@ -20,13 +20,17 @@ Order events
There are multiple signals that will be sent out in the ordering cycle:
.. 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_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
Frontend
--------
.. automodule:: pretix.presale.signals
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, checkout_flow_steps, order_info, order_meta_from_request
:members: html_head, html_footer, footer_link, front_page_top, front_page_bottom, fee_calculation_for_cart, contact_form_fields, question_form_fields, checkout_confirm_messages, checkout_confirm_page_content, checkout_all_optional, html_page_header, sass_preamble, sass_postamble, render_seating_plan, checkout_flow_steps, position_info
.. automodule:: pretix.presale.signals
:members: order_info, order_meta_from_request
Request flow
""""""""""""
@@ -45,11 +49,11 @@ Backend
.. automodule:: pretix.control.signals
: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
:members: logentry_display, logentry_object_link, requiredaction_display
:members: logentry_display, logentry_object_link, requiredaction_display, timeline_events
Vouchers
""""""""
+2
View File
@@ -21,10 +21,12 @@ Your should install the following on your system:
* Python 3.5 or newer
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
* ``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``)
* ``libssl`` (Debian package: ``libssl-dev``)
* ``libxml2`` (Debian package ``libxml2-dev``)
* ``libxslt`` (Debian package ``libxslt1-dev``)
* ``libenchant1c2a`` (Debian package ``libenchant1c2a``)
* ``msgfmt`` (Debian package ``gettext``)
* ``git``
View File
+1
View File
@@ -40,6 +40,7 @@ frontend
frontpage
gettext
gunicorn
guid
hardcoded
hostname
idempotency
+1 -1
View File
@@ -1 +1 @@
__version__ = "2.7.0"
__version__ = "2.9.0.dev0"
+3 -1
View File
@@ -1,4 +1,5 @@
from django.contrib.auth.models import AnonymousUser
from django_scopes import scopes_disabled
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
@@ -12,7 +13,8 @@ class DeviceTokenAuthentication(TokenAuthentication):
def authenticate_credentials(self, key):
model = self.get_model()
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:
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.base.models import Device, Event, User
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 (
SessionInvalid, SessionReauthRequired, assert_session_valid,
)
@@ -50,9 +50,6 @@ class EventPermission(BasePermission):
return False
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):
return False
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.db import transaction
from django.http import HttpRequest, HttpResponse, JsonResponse
from django.urls import resolve
from django.utils.timezone import now
from django_scopes import scope
from rest_framework import status
from pretix.api.models import ApiCall
from pretix.base.models import Organizer
class IdempotencyMiddleware:
@@ -89,3 +92,21 @@ class IdempotencyMiddleware:
for k, v in json.loads(call.response_headers).values():
r[k] = v
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.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
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
seat = InlineSeatSerializer()
class Meta:
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers',)
'answers', 'seat')
class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta:
model = CartPosition
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):
answers_data = validated_data.pop('answers')
@@ -71,6 +73,22 @@ class CartPositionCreateSerializer(I18nAwareModelSerializer):
validated_data['attendee_name_parts'] = {
'_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)
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.event import SubEvent
from pretix.base.models.items import SubEventItem, SubEventItemVariation
from pretix.base.services.seating import (
SeatProtected, generate_seats, validate_plan_change,
)
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):
def to_representation(self, obj):
@@ -45,12 +64,14 @@ class PluginsField(Field):
class EventSerializer(I18nAwareModelSerializer):
meta_data = MetaDataField(required=False, source='*')
plugins = PluginsField(required=False, source='*')
seat_category_mapping = SeatCategoryMappingField(source='*', required=False)
class Meta:
model = Event
fields = ('name', 'slug', 'live', 'testmode', 'currency', 'date_from',
'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):
data = super().validate(data)
@@ -61,6 +82,9 @@ class EventSerializer(I18nAwareModelSerializer):
Event.clean_dates(data.get('date_from'), data.get('date_to'))
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
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))
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 (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):
from pretix.base.plugins import get_all_plugins
@@ -109,6 +154,7 @@ class EventSerializer(I18nAwareModelSerializer):
@transaction.atomic
def create(self, validated_data):
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(','))
event = super().create(validated_data)
@@ -120,6 +166,10 @@ class EventSerializer(I18nAwareModelSerializer):
value=value
)
# Seats
if event.seating_plan:
generate_seats(event, None, event.seating_plan, {})
# Plugins
if plugins is not None:
event.set_active_plugins(plugins)
@@ -131,6 +181,7 @@ class EventSerializer(I18nAwareModelSerializer):
def update(self, instance, validated_data):
meta_data = validated_data.pop('meta_data', None)
plugins = validated_data.pop('plugins', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
event = super().update(instance, validated_data)
# Meta data
@@ -151,6 +202,29 @@ class EventSerializer(I18nAwareModelSerializer):
if prop.name not in meta_data:
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
if plugins is not None:
event.set_active_plugins(plugins)
@@ -164,6 +238,7 @@ class CloneEventSerializer(EventSerializer):
def create(self, validated_data):
plugins = validated_data.pop('plugins', None)
is_public = validated_data.pop('is_public', None)
testmode = validated_data.pop('testmode', None)
new_event = super().create(validated_data)
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)
if is_public is not None:
new_event.is_public = is_public
if testmode is not None:
new_event.testmode = testmode
new_event.save()
return new_event
@@ -193,14 +270,15 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_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)
meta_data = MetaDataField(source='*')
class Meta:
model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location', 'event', 'is_public',
'item_price_overrides', 'variation_price_overrides', 'meta_data')
'presale_start', 'presale_end', 'location', 'event', 'is_public', 'seating_plan',
'item_price_overrides', 'variation_price_overrides', 'meta_data', 'seat_category_mapping')
def validate(self, data):
data = super().validate(data)
@@ -222,6 +300,25 @@ class SubEventSerializer(I18nAwareModelSerializer):
def validate_variation_price_overrides(self, 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
def meta_properties(self):
return {
@@ -239,6 +336,7 @@ class SubEventSerializer(I18nAwareModelSerializer):
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 {}
meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().create(validated_data)
for item_price_override_data in item_price_overrides_data:
@@ -254,6 +352,18 @@ class SubEventSerializer(I18nAwareModelSerializer):
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
@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 {}
variation_price_overrides_data = validated_data.pop('subeventitemvariation_set') if 'subeventitemvariation_set' in validated_data else {}
meta_data = validated_data.pop('meta_data', None)
seat_category_mapping = validated_data.pop('seat_category_mapping', None)
subevent = super().update(instance, validated_data)
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:
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
+32 -8
View File
@@ -1,5 +1,4 @@
import json
from collections import Counter
from decimal import Decimal
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.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, SubEvent,
OrderPosition, Question, QuestionAnswer, Seat, SubEvent,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
@@ -71,6 +70,13 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta:
model = Seat
fields = ('id', 'name', 'seat_guid')
class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -166,12 +172,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
class Meta:
model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'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):
super().__init__(*args, **kwargs)
@@ -430,11 +437,12 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
class Meta:
model = OrderPosition
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):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -615,8 +623,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
ia = None
with self.context['event'].lock() as now_dt:
quotadiff = Counter()
free_seats = set()
seats_seen = set()
consume_carts = validated_data.pop('consume_carts', [])
delete_cps = []
quota_avail_cache = {}
@@ -630,7 +638,8 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1
if cp.expires > now_dt:
quotadiff.subtract(quotas)
if cp.seat:
free_seats.add(cp.seat)
delete_cps.append(cp)
errs = [{} for p in positions_data]
@@ -658,7 +667,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):
raise ValidationError({'positions': errs})
+13 -1
View File
@@ -1,8 +1,20 @@
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 Meta:
model = Organizer
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
fields = ('id', 'code', 'max_usages', 'redeemed', 'valid_until', 'block_quota',
'allow_ignore_quota', 'price_mode', 'value', 'item', 'variation', 'quota',
'tag', 'comment', 'subevent')
'tag', 'comment', 'subevent', 'show_hidden_items')
read_only_fields = ('id', 'redeemed')
list_serializer_class = VoucherListSerializer
+3
View File
@@ -2,6 +2,7 @@ from datetime import timedelta
from django.dispatch import Signal, receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.api.models import ApiCall, WebHookCall
from pretix.base.signals import periodic_task
@@ -17,10 +18,12 @@ instances.
@receiver(periodic_task)
@scopes_disabled()
def cleanup_webhook_logs(sender, **kwargs):
WebHookCall.objects.filter(datetime__lte=now() - timedelta(days=30)).delete()
@receiver(periodic_task)
@scopes_disabled()
def cleanup_api_logs(sender, **kwargs):
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'subevents', event.SubEventViewSet)
orga_router.register(r'webhooks', webhooks.WebHookViewSet)
orga_router.register(r'seatingplans', organizer.SeatingPlanViewSet)
event_router = routers.DefaultRouter()
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(
event=self.request.event,
cart_id__endswith="@api"
)
).select_related('seat').prefetch_related('answers')
def get_serializer_context(self):
ctx = super().get_serializer_context()
+13 -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.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.fields import DateTimeField
@@ -24,11 +25,11 @@ from pretix.base.services.checkin import (
)
from pretix.helpers.database import FixedOrderBy
class CheckinListFilter(FilterSet):
class Meta:
model = CheckinList
fields = ['subevent']
with scopes_disabled():
class CheckinListFilter(FilterSet):
class Meta:
model = CheckinList
fields = ['subevent']
class CheckinListViewSet(viewsets.ModelViewSet):
@@ -146,15 +147,16 @@ class CheckinListViewSet(viewsets.ModelViewSet):
return Response(response)
class CheckinOrderPositionFilter(OrderPositionFilter):
with scopes_disabled():
class CheckinOrderPositionFilter(OrderPositionFilter):
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(last_checked_in__isnull=not value)
class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = CheckinListOrderPositionSerializer
queryset = OrderPosition.objects.none()
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, RichOrderingFilter)
ordering = ('attendee_name_cached', 'positionid')
ordering_fields = (
@@ -229,7 +231,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address'
'item', 'variation', 'item__category', 'addon_to', 'order', 'order__invoice_address', 'seat'
)
else:
qs = qs.prefetch_related(
@@ -239,7 +241,7 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
),
'answers', 'answers__options', 'answers__question',
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:
qs = qs.filter(item__in=self.checkinlist.limit_products.values_list('id', flat=True))
+82 -72
View File
@@ -3,6 +3,7 @@ from django.db import transaction
from django.db.models import ProtectedError, Q
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
@@ -18,51 +19,51 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
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):
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 Meta:
model = Event
fields = ['is_public', 'live', 'has_subevents']
class Meta:
model = Event
fields = ['is_public', 'live', 'has_subevents']
def ends_after_qs(self, queryset, name, value):
expr = (
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))
def ends_after_qs(self, queryset, name, value):
expr = (
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)
else:
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()))
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)
else:
return queryset.exclude(expr)
if value:
return queryset.filter(expr)
else:
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):
@@ -72,6 +73,8 @@ class EventViewSet(viewsets.ModelViewSet):
lookup_url_kwarg = 'event'
permission_classes = (EventCRUDPermission,)
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
ordering = ('slug',)
ordering_fields = ('date_from', 'slug')
filterset_class = EventFilter
def get_queryset(self):
@@ -83,7 +86,7 @@ class EventViewSet(viewsets.ModelViewSet):
)
return qs.prefetch_related(
'meta_values', 'meta_values__property'
'meta_values', 'meta_values__property', 'seat_category_mappings'
)
def perform_update(self, serializer):
@@ -180,41 +183,42 @@ class CloneEventViewSet(viewsets.ModelViewSet):
)
class SubEventFilter(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')
with scopes_disabled():
class SubEventFilter(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 Meta:
model = SubEvent
fields = ['active', 'event__live']
class Meta:
model = SubEvent
fields = ['active', 'event__live']
def ends_after_qs(self, queryset, name, value):
expr = 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(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
if value:
def ends_after_qs(self, queryset, name, value):
expr = 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)
else:
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)
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)
else:
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):
@@ -238,12 +242,18 @@ class SubEventViewSet(ConditionalListView, viewsets.ModelViewSet):
event__in=self.request.user.get_events_with_any_permission()
)
return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set'
'subeventitem_set', 'subeventitemvariation_set', 'seat_category_mappings'
)
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
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(
'pretix.subevent.changed',
user=self.request.user,
+37 -19
View File
@@ -3,6 +3,7 @@ from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.decorators import action
from rest_framework.exceptions import PermissionDenied
@@ -21,19 +22,19 @@ from pretix.base.models import (
)
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):
tax_rate = django_filters.CharFilter(method='tax_rate_qs')
def tax_rate_qs(self, queryset, name, value):
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):
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)
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class Meta:
model = Item
fields = ['active', 'category', 'admission', 'tax_rate', 'free_price']
class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -65,7 +66,14 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
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(
'pretix.event.item.changed',
user=self.request.user,
@@ -312,10 +320,11 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
with scopes_disabled():
class QuestionFilter(FilterSet):
class Meta:
model = Question
fields = ['ask_during_checkin', 'required', 'identifier']
class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -411,10 +420,11 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
super().perform_destroy(instance)
class QuotaFilter(FilterSet):
class Meta:
model = Quota
fields = ['subevent']
with scopes_disabled():
class QuotaFilter(FilterSet):
class Meta:
model = Quota
fields = ['subevent']
class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
@@ -452,9 +462,17 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
return ctx
def perform_update(self, serializer):
original_data = self.get_serializer(instance=serializer.instance).data
current_subevent = serializer.instance.subevent
serializer.save(event=self.request.event)
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
serializer.instance.log_action(
'pretix.event.quota.changed',
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.translation import ugettext as _
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.decorators import action
from rest_framework.exceptions import (
@@ -50,17 +51,17 @@ from pretix.base.signals import (
)
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):
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 Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class OrderViewSet(viewsets.ModelViewSet):
@@ -92,8 +93,8 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
'item__category', 'addon_to', 'seat',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
)
)
)
@@ -102,7 +103,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch(
'positions',
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'):
serializer.instance.email_known_to_work = False
serializer.instance.log_action(
'pretix.event.order.contact.changed',
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)
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
with scopes_disabled():
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value)
)
def search_qs(self, queryset, name, value):
return queryset.filter(
Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value)
)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value)
def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not 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))
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))
class Meta:
model = OrderPosition
fields = {
'item': ['exact', 'in'],
'variation': ['exact', 'in'],
'secret': ['exact'],
'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'],
'voucher__code': ['exact'],
'voucher': ['exact'],
}
class Meta:
model = OrderPosition
fields = {
'item': ['exact', 'in'],
'variation': ['exact', 'in'],
'secret': ['exact'],
'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'],
'voucher__code': ['exact'],
'voucher': ['exact'],
}
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none()
queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
@@ -608,13 +611,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
)
))
).select_related(
'item', 'variation', 'item__category', 'addon_to'
'item', 'variation', 'item__category', 'addon_to', 'seat'
)
else:
qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question'
).select_related(
'item', 'order', 'order__event', 'order__event__organizer'
'item', 'order', 'order__event', 'order__event__organizer', 'seat'
)
return qs
@@ -959,22 +962,23 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer.save()
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs')
number = django_filters.CharFilter(method='nr_qs')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
with scopes_disabled():
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs')
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):
return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value)
def refers_qs(self, queryset, name, value):
return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value)
def nr_qs(self, queryset, name, value):
return queryset.filter(nr__iexact=value)
def nr_qs(self, queryset, name, value):
return queryset.filter(nr__iexact=value)
class Meta:
model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
class Meta:
model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
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.serializers.organizer import OrganizerSerializer
from pretix.base.models import Organizer
from pretix.api.serializers.organizer import (
OrganizerSerializer, SeatingPlanSerializer,
)
from pretix.base.models import Organizer, SeatingPlan
from pretix.helpers.dicts import merge_dicts
class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
@@ -10,6 +14,9 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
queryset = Organizer.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'organizer'
filter_backends = (filters.OrderingFilter,)
ordering = ('slug',)
ordering_fields = ('name', 'slug')
def get_queryset(self):
if self.request.user.is_authenticated:
@@ -27,3 +34,50 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else:
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 (
BooleanFilter, DjangoFilterBackend, FilterSet,
)
from django_scopes import scopes_disabled
from rest_framework import status, viewsets
from rest_framework.decorators import action
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.base.models import Voucher
with scopes_disabled():
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
class VoucherFilter(FilterSet):
active = BooleanFilter(method='filter_active')
class Meta:
model = Voucher
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
class Meta:
model = Voucher
fields = ['code', 'max_usages', 'redeemed', 'block_quota', 'allow_ignore_quota',
'price_mode', 'value', 'item', 'variation', 'quota', 'tag', 'subevent']
def filter_active(self, queryset, name, value):
if value:
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())))
def filter_active(self, queryset, name, value):
if value:
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):
+9 -8
View File
@@ -1,5 +1,6 @@
import django_filters
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import viewsets
from rest_framework.decorators import action
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.waitinglist import WaitingListException
with scopes_disabled():
class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
class WaitingListFilter(FilterSet):
has_voucher = django_filters.rest_framework.BooleanFilter(method='has_voucher_qs')
def has_voucher_qs(self, queryset, name, value):
return queryset.filter(voucher__isnull=not value)
def has_voucher_qs(self, queryset, name, value):
return queryset.filter(voucher__isnull=not value)
class Meta:
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):
+46 -44
View File
@@ -8,6 +8,7 @@ from celery.exceptions import MaxRetriesExceededError
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from django_scopes import scope, scopes_disabled
from requests import RequestException
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)
def send_webhook(self, logentry_id: int, action_type: str, webhook_id: int):
# 9 retries with 2**(2*x) timing is roughly 72 hours
logentry = LogEntry.all.get(id=logentry_id)
webhook = WebHook.objects.get(id=webhook_id)
with scopes_disabled():
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()
event_type = types.get(action_type)
if not event_type or not webhook.enabled:
return # Ignore, e.g. plugin not installed
payload = event_type.build_payload(logentry)
t = time.time()
payload = event_type.build_payload(logentry)
t = time.time()
try:
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
)
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=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
webhook.save()
elif resp.status_code > 299:
try:
resp = requests.post(
webhook.target_url,
json=payload,
allow_redirects=False
)
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=resp.status_code,
payload=json.dumps(payload),
response_body=resp.text[:1024 * 1024],
success=200 <= resp.status_code <= 299
)
if resp.status_code == 410:
webhook.enabled = False
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))
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))
except MaxRetriesExceededError:
pass
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 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.templatetags.rich_text import markdown_compile_email
@@ -44,7 +44,8 @@ class BaseHTMLMailRenderer:
def __str__(self):
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.
@@ -52,6 +53,7 @@ class BaseHTMLMailRenderer:
:param plain_signature: The signature with event organizer contact details in plain text.
:param subject: The email subject.
: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
"""
raise NotImplementedError()
@@ -95,7 +97,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self):
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)
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
@@ -116,6 +118,9 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
if order:
htmlctx['order'] = order
if position:
htmlctx['position'] = position
tpl = get_template(self.template_name)
body_html = inline_css(tpl.render(htmlctx))
return body_html
+4
View File
@@ -40,6 +40,7 @@ class AnswerFilesExporter(BaseExporter):
if form_data.get('questions'):
qs = qs.filter(question__in=form_data['questions'])
with tempfile.TemporaryDirectory() as d:
any = False
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs:
if i.file:
@@ -51,9 +52,12 @@ class AnswerFilesExporter(BaseExporter):
i.question.pk,
os.path.basename(i.file.name).split('.', 1)[1]
)
any = True
zipf.writestr(fname, i.file.read())
i.file.close()
if not any:
return None
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_answers.zip'.format(self.event.slug), 'application/zip', zipf.read()
+1 -1
View File
@@ -93,7 +93,7 @@ class DekodiNREIExporter(BaseExporter):
'PTNo15': p.full_id or '',
})
elif p.provider.startswith('stripe'):
src = p.info_data.get("source", "{}")
src = p.info_data.get("source", p.info_data)
payments.append({
'PTID': '81',
'PTN': 'Stripe',
+6
View File
@@ -46,6 +46,7 @@ class InvoiceExporter(BaseExporter):
qs = qs.filter(date__lte=date_value)
with tempfile.TemporaryDirectory() as d:
any = False
with ZipFile(os.path.join(d, 'tmp.zip'), 'w') as zipf:
for i in qs:
try:
@@ -54,14 +55,19 @@ class InvoiceExporter(BaseExporter):
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close()
except FileNotFoundError:
invoice_pdf_task.apply(args=(i.pk,))
i.refresh_from_db()
i.file.open('rb')
zipf.writestr('{}.pdf'.format(i.number), i.file.read())
any = True
i.file.close()
if not any:
return None
with open(os.path.join(d, 'tmp.zip'), 'rb') as zipf:
return '{}_invoices.zip'.format(self.event.slug), 'application/zip', zipf.read()
+30 -2
View File
@@ -11,8 +11,9 @@ from django.contrib import messages
from django.core.exceptions import ValidationError
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.translation import ugettext_lazy as _
from django_countries.fields import CountryField
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 (
BusinessBooleanRadio, DatePickerWidget, SplitDateTimePickerWidget,
@@ -351,6 +352,27 @@ class BaseInvoiceAddressForm(forms.ModelForm):
self.request = kwargs.pop('request', None)
self.validate_vat_id = kwargs.pop('validate_vat_id')
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)
if not event.settings.invoice_address_vatid:
del self.fields['vat_id']
@@ -402,6 +424,12 @@ class BaseInvoiceAddressForm(forms.ModelForm):
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:
pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
@@ -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", *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", *args, **options)
@@ -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,
)
]
+1
View File
@@ -24,6 +24,7 @@ from .orders import (
from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
)
from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule
from .vouchers import Voucher
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.translation import ugettext_lazy as _
from django_otp.models import Device
from django_scopes import scopes_disabled
from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri
@@ -283,6 +284,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True
return False
@scopes_disabled()
def get_events_with_any_permission(self, request=None):
"""
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))
)
@scopes_disabled()
def get_events_with_permission(self, permission, request=None):
"""
Returns a queryset of events the user has a specific permissions to.
+37 -1
View File
@@ -6,7 +6,9 @@ from django.db import models
from django.db.models.constants import LOOKUP_SEP
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.urls import reverse
from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from pretix.helpers.json import CustomJSONEncoder
@@ -113,6 +115,40 @@ class LoggedModel(models.Model, LoggingMixin):
class Meta:
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):
"""
Returns all log entries that are attached to this object.
@@ -122,7 +158,7 @@ class LoggedModel(models.Model, LoggingMixin):
from .log import LogEntry
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')
+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.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.models import LoggedModel
@@ -20,6 +21,8 @@ class CheckinList(LoggedModel):
'order have not been paid. This only works with pretixdesk '
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
objects = ScopedManager(organizer='event__organizer')
class Meta:
ordering = ('subevent__date_from', 'name')
@@ -167,6 +170,8 @@ class Checkin(models.Model):
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
)
objects = ScopedManager(organizer='position__order__event__organizer')
class Meta:
unique_together = (('list', 'position'),)
+6
View File
@@ -4,10 +4,12 @@ from django.db import models
from django.db.models import Max
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from pretix.base.models import LoggedModel
@scopes_disabled()
def generate_serial():
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
while Device.objects.filter(unique_serial=serial).exists():
@@ -15,6 +17,7 @@ def generate_serial():
return serial
@scopes_disabled()
def generate_initialization_token():
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(initialization_token=token).exists():
@@ -22,6 +25,7 @@ def generate_initialization_token():
return token
@scopes_disabled()
def generate_api_token():
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(api_token=token).exists():
@@ -71,6 +75,8 @@ class Device(LoggedModel):
null=True, blank=True
)
objects = ScopedManager(organizer='organizer')
class Meta:
unique_together = (('organizer', 'device_id'),)
+71
View File
@@ -17,6 +17,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel
@@ -335,6 +336,10 @@ class Event(EventMixin, LoggedModel):
verbose_name=_('Event series'),
default=False
)
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
objects = ScopedManager(organizer='organizer')
class Meta:
verbose_name = _("Event")
@@ -345,6 +350,26 @@ class Event(EventMixin, LoggedModel):
def __str__(self):
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
def presale_has_ended(self):
if self.has_subevents:
@@ -445,6 +470,7 @@ class Event(EventMixin, LoggedModel):
self.plugins = other.plugins
self.is_public = other.is_public
self.testmode = other.testmode
self.save()
tax_map = {}
@@ -527,6 +553,24 @@ class Event(EventMixin, LoggedModel):
for i in items:
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():
s.object = self
s.pk = None
@@ -870,10 +914,14 @@ class SubEvent(EventMixin, LoggedModel):
null=True, blank=True,
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')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Date in event series")
verbose_name_plural = _("Dates in event series")
@@ -882,6 +930,28 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self):
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
def settings(self):
return self.event.settings
@@ -940,6 +1010,7 @@ class SubEvent(EventMixin, LoggedModel):
raise ValidationError(_('One or more variations do not belong to this event.'))
@scopes_disabled()
def generate_invite_token():
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.translation import pgettext
from django_countries.fields import CountryField
from django_scopes import ScopedManager
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)
internal_reference = models.TextField(blank=True)
objects = ScopedManager(organizer='event__organizer')
@staticmethod
def _to_numeric_invoice_number(number):
return '{:05d}'.format(int(number))
+44 -21
View File
@@ -17,11 +17,13 @@ from django.utils.functional import cached_property
from django.utils.timezone import is_naive, make_aware, now
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 pretix.base.models import fields
from pretix.base.models.base import LoggedModel
from pretix.base.models.tax import TaxedPrice
from pretix.base.signals import quota_availability
from .event import Event, SubEvent
@@ -155,28 +157,40 @@ class SubEventItemVariation(models.Model):
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))
qs = qs.filter(q)
vouchq = Q(hide_without_voucher=False)
if voucher and voucher.show_hidden_items:
if voucher.item_id:
vouchq = Q(pk=voucher.item_id)
elif voucher.quota_id:
vouchq = Q(quotas__in=[voucher.quota_id])
return qs.filter(vouchq)
class ItemQuerySet(models.QuerySet):
def filter_available(self, 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))
qs = self.filter(q)
return filter_available(self, channel, voucher, allow_addons)
vouchq = Q(hide_without_voucher=False)
if voucher:
if voucher.item_id:
vouchq |= Q(pk=voucher.item_id)
qs = qs.filter(pk=voucher.item_id)
elif voucher.quota_id:
qs = qs.filter(quotas__in=[voucher.quota_id])
return qs.filter(vouchq)
class ItemQuerySetManager(ScopedManager(organizer='event__organizer').__class__):
def __init__(self):
super().__init__()
self._queryset_class = ItemQuerySet
def filter_available(self, channel='web', voucher=None, allow_addons=False):
return filter_available(self.get_queryset(), channel, voucher, allow_addons)
class Item(LoggedModel):
@@ -226,7 +240,7 @@ class Item(LoggedModel):
:type sales_channels: bool
"""
objects = ItemQuerySet.as_manager()
objects = ItemQuerySetManager()
event = models.ForeignKey(
Event,
@@ -328,7 +342,7 @@ class Item(LoggedModel):
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
default=False,
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(
verbose_name=_('Only sell this product as part of a bundle'),
@@ -591,6 +605,8 @@ class ItemVariation(models.Model):
'discounted one. This is just a cosmetic setting and will not actually impact pricing.')
)
objects = ScopedManager(organizer='item__event__organizer')
class Meta:
verbose_name = _("Product variation")
verbose_name_plural = _("Product variations")
@@ -985,6 +1001,8 @@ class Question(LoggedModel):
)
dependency_value = models.TextField(null=True, blank=True)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Question")
verbose_name_plural = _("Questions")
@@ -1234,6 +1252,8 @@ class Quota(LoggedModel):
cached_availability_paid_orders = models.PositiveIntegerField(null=True, blank=True)
cached_availability_time = models.DateTimeField(null=True, blank=True)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Quota")
verbose_name_plural = _("Quotas")
@@ -1292,6 +1312,9 @@ class Quota(LoggedModel):
return _cache[self.pk]
now_dt = now_dt or now()
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
self.event.cache.delete('item_quota_cache')
rewrite_cache = count_waitinglist and (
+211 -55
View File
@@ -1,4 +1,5 @@
import copy
import hashlib
import json
import logging
import os
@@ -14,7 +15,7 @@ from django.db import models, transaction
from django.db.models import (
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.dispatch import receiver
from django.urls import reverse
@@ -25,6 +26,7 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField
@@ -180,6 +182,12 @@ class Order(LockModel, LoggedModel):
default=False
)
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:
verbose_name = _("Order")
@@ -190,6 +198,8 @@ class Order(LockModel, LoggedModel):
return self.full_code
def gracefully_delete(self, user=None, auth=None):
from . import Voucher
if not self.testmode:
raise TypeError("Only test mode orders can be deleted.")
self.event.log_action(
@@ -198,6 +208,12 @@ class Order(LockModel, LoggedModel):
'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).delete()
OrderFee.all.filter(order=self).delete()
@@ -206,6 +222,9 @@ class Order(LockModel, LoggedModel):
self.event.cache.delete('complain_testmode_orders')
self.delete()
def email_confirm_hash(self):
return hashlib.sha256(settings.SECRET_KEY.encode() + self.secret.encode()).hexdigest()[:9]
@property
def fees(self):
"""
@@ -215,6 +234,7 @@ class Order(LockModel, LoggedModel):
return self.all_fees(manager='objects')
@cached_property
@scopes_disabled()
def count_positions(self):
if hasattr(self, 'pcnt'):
return self.pcnt or 0
@@ -238,6 +258,7 @@ class Order(LockModel, LoggedModel):
return None
@property
@scopes_disabled()
def payment_refund_sum(self):
payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
@@ -249,6 +270,7 @@ class Order(LockModel, LoggedModel):
return payment_sum - refund_sum
@property
@scopes_disabled()
def pending_sum(self):
total = self.total
if self.status == Order.STATUS_CANCELED:
@@ -423,6 +445,7 @@ class Order(LockModel, LoggedModel):
return round_decimal(fee, self.event.currency)
@property
@scopes_disabled()
def user_cancel_allowed(self) -> bool:
"""
Returns whether or not this order can be canceled by the user.
@@ -607,7 +630,7 @@ class Order(LockModel, LoggedModel):
), tz)
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 = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."),
@@ -615,29 +638,37 @@ class Order(LockModel, LoggedModel):
"payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.')
}
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if not force:
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last and not ignore_date:
if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING:
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 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 = {
'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
}
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 = {}
try:
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)
if len(quotas) == 0:
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],
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):
auth=None, attach_tickets=False, position: 'OrderPosition'=None):
"""
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 sender: Custom email sender.
: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
@@ -693,12 +727,16 @@ class Order(LockModel, LoggedModel):
with language(self.locale):
recipient = self.email
if position and position.attendee_email:
recipient = position.attendee_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
self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets,
position=position
)
except SendMailException:
raise
@@ -710,6 +748,7 @@ class Order(LockModel, LoggedModel):
data={
'subject': subject,
'message': email_content,
'position': position.positionid if position else None,
'recipient': recipient,
'invoices': [i.pk for i in invoices] if invoices else [],
'attach_tickets': attach_tickets,
@@ -729,9 +768,10 @@ class Order(LockModel, LoggedModel):
email_template = self.event.settings.mail_text_resend_link
email_context = {
'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,
'secret': self.secret
'secret': self.secret,
'hash': self.email_confirm_hash()
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
@@ -797,6 +837,8 @@ class QuestionAnswer(models.Model):
max_length=255
)
objects = ScopedManager(organizer='question__event__organizer')
@property
def backend_file_url(self):
if self.file:
@@ -904,6 +946,8 @@ class AbstractPosition(models.Model):
:type voucher: Voucher
:param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str
:param seat: Seat, if reserved seating is used.
:type seat: Seat
"""
subevent = models.ForeignKey(
SubEvent,
@@ -950,6 +994,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Meta information"),
null=True, blank=True
)
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
class Meta:
abstract = True
@@ -961,6 +1008,10 @@ class AbstractPosition(models.Model):
else:
return {}
@meta_info_data.setter
def meta_info_data(self, d):
self.meta_info = json.dumps(d)
def cache_answers(self, all=True):
"""
Creates two properties on the object.
@@ -1116,6 +1167,8 @@ class OrderPayment(models.Model):
)
migrated = models.BooleanField(default=False)
objects = ScopedManager(organizer='order__event__organizer')
class Meta:
ordering = ('local_id',)
@@ -1143,8 +1196,8 @@ class OrderPayment(models.Model):
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date)
if not force and can_be_paid is not True:
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
if can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid
}, user=user, auth=auth)
@@ -1163,7 +1216,8 @@ class OrderPayment(models.Model):
self.order.log_action('pretix.event.order.overpaid', {}, user=user, auth=auth)
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
payment is required
@@ -1184,8 +1238,6 @@ class OrderPayment(models.Model):
: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.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with transaction.atomic():
locked_instance = OrderPayment.objects.select_for_update().get(pk=self.pk)
@@ -1194,7 +1246,7 @@ class OrderPayment(models.Model):
return
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.save(update_fields=['state', 'payment_date', 'info'])
@@ -1249,35 +1301,77 @@ class OrderPayment(models.Model):
)
if send_mail:
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', kwargs={
'order': self.order.code,
'secret': self.order.secret
}),
'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')
self._send_paid_mail(invoice, user, mail_text)
if self.order.event.settings.mail_send_order_paid_attendee:
for p in self.order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != self.order.email:
self._send_paid_mail_attendee(p, user)
def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.order.locale):
name_scheme = PERSON_NAME_SCHEMES[self.order.event.settings.name_scheme]
email_template = self.order.event.settings.mail_text_order_paid_attendee
email_context = {
'event': self.order.event.name,
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
'url': build_absolute_uri(self.order.event, 'presale:event.order.position', kwargs={
'order': self.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 = _('Event registration confirmed: %(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=[], 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
def refunded_amount(self):
@@ -1431,6 +1525,8 @@ class OrderRefund(models.Model):
null=True, blank=True
)
objects = ScopedManager(organizer='order__event__organizer')
class Meta:
ordering = ('local_id',)
@@ -1492,7 +1588,7 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs)
class ActivePositionManager(models.Manager):
class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
def get_queryset(self):
return super().get_queryset().filter(canceled=False)
@@ -1569,7 +1665,7 @@ class OrderFee(models.Model):
)
canceled = models.BooleanField(default=False)
all = models.Manager()
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
@property
@@ -1666,6 +1762,7 @@ class OrderPosition(AbstractPosition):
verbose_name=_('Tax value')
)
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(
max_length=16,
unique=True,
@@ -1673,7 +1770,7 @@ class OrderPosition(AbstractPosition):
)
canceled = models.BooleanField(default=False)
all = models.Manager()
all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager()
class Meta:
@@ -1773,6 +1870,7 @@ class OrderPosition(AbstractPosition):
return super().save(*args, **kwargs)
@scopes_disabled()
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)
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
@@ -1789,6 +1887,60 @@ class OrderPosition(AbstractPosition):
def event(self):
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):
"""
@@ -1826,6 +1978,8 @@ class CartPosition(AbstractPosition):
)
is_bundled = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Cart position")
verbose_name_plural = _("Cart positions")
@@ -1875,6 +2029,8 @@ class InvoiceAddress(models.Model):
blank=True
)
objects = ScopedManager(organizer='order__event__organizer')
def save(self, **kwargs):
if self.order:
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()
+18
View File
@@ -8,6 +8,9 @@ from django.db.models import Q
from django.utils.crypto import get_random_string
from django.utils.timezone import now
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 .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)
@scopes_disabled()
def generate_code(prefix=None):
while True:
code = _generate_random_code(prefix=prefix)
@@ -172,6 +176,12 @@ class Voucher(LoggedModel):
help_text=_("The text entered in this field will not be visible to the user and is available for your "
"convenience.")
)
show_hidden_items = models.BooleanField(
verbose_name=_("Shows hidden products that match this voucher"),
default=True
)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Voucher")
@@ -391,3 +401,11 @@ class Voucher(LoggedModel):
"""
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()
return self.item.seat_category_mappings.filter(**kwargs).exists()
+3
View File
@@ -4,6 +4,7 @@ from django.core.exceptions import ValidationError
from django.db import models, transaction
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.i18n import language
from pretix.base.models import Voucher
@@ -67,6 +68,8 @@ class WaitingListEntry(LoggedModel):
)
priority = models.IntegerField(default=0)
objects = ScopedManager(organizer='event__organizer')
class Meta:
verbose_name = _("Waiting list entry")
verbose_name_plural = _("Waiting list entries")
+5
View File
@@ -238,6 +238,11 @@ DEFAULT_VARIABLES = OrderedDict((
"TIME_FORMAT"
) 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'))
}),
))
+1 -1
View File
@@ -71,7 +71,7 @@ class RelativeDateWrapper:
else:
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)
if self.data.time:
new_date = new_date.replace(
+100 -39
View File
@@ -6,15 +6,16 @@ from typing import List, Optional
from celery.exceptions import MaxRetriesExceededError
from django.core.exceptions import ValidationError
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.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext as _
from django_scopes import scopes_disabled
from pretix.base.i18n import language
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation,
Voucher,
CartPosition, Event, InvoiceAddress, Item, ItemBundle, ItemVariation, Seat,
SeatCategoryMapping, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
@@ -23,7 +24,7 @@ from pretix.base.reldate import RelativeDateWrapper
from pretix.base.services.checkin import _save_answers
from pretix.base.services.locking import LockTimeoutException, NoLockManager
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.templatetags.rich_text import rich_text
from pretix.celery_app import app
@@ -90,15 +91,20 @@ error_messages = {
'product %(base)s.'),
'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.'),
'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:
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',))
ExtendOperation = namedtuple('ExtendOperation', ('position', 'count', 'item', 'variation', 'price', 'voucher',
'quotas', 'subevent'))
'quotas', 'subevent', 'seat'))
order = {
RemoveOperation: 10,
ExtendOperation: 20,
@@ -116,6 +122,7 @@ class CartManager:
self._items_cache = {}
self._subevents_cache = {}
self._variations_cache = {}
self._seated_cache = {}
self._expiry = None
self.invoice_address = invoice_address
self._widget_data = widget_data or {}
@@ -127,6 +134,11 @@ class CartManager:
Q(cart_id=self.cart_id) & Q(event=self.event)
).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):
self._expiry = self.now_dt + timedelta(minutes=self.event.settings.get('reservation_time', as_type=int))
@@ -187,6 +199,8 @@ class CartManager:
i.pk: i
for i in self.event.items.select_related('category').prefetch_related(
'addons', 'bundles', 'addons__addon_category', 'quotas'
).annotate(
has_variations=Count('variations'),
).filter(
id__in=[i for i in item_ids if i and i not in self._items_cache]
)
@@ -211,10 +225,7 @@ class CartManager:
def _check_item_constraints(self, op):
if isinstance(op, self.AddOperation) or isinstance(op, self.ExtendOperation):
if op.item.require_voucher and op.voucher is None:
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.require_voucher or op.item.hide_without_voucher) and (op.voucher is None or not op.voucher.show_hidden_items):
raise CartError(error_messages['voucher_required'])
if not op.item.is_available() or (op.variation and not op.variation.active):
@@ -223,6 +234,12 @@ class CartManager:
if self._sales_channel not in op.item.sales_channels:
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):
raise CartError(error_messages['voucher_invalid_item'])
@@ -238,6 +255,16 @@ class CartManager:
if op.subevent and op.subevent.presale_has_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:
tlv = self.event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if tlv:
@@ -300,6 +327,13 @@ class CartManager:
def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
'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(
'item__quotas',
'variation__quotas',
@@ -312,6 +346,8 @@ class CartManager:
if cp.pk in removed_positions or (cp.addon_to_id and cp.addon_to_id in removed_positions):
continue
cp.item.requires_seat = cp.requires_seat
if cp.is_bundled:
try:
bundle = cp.addon_to.item.bundles.get(bundled_item=cp.item, bundled_variation=cp.variation)
@@ -358,7 +394,7 @@ class CartManager:
op = self.ExtendOperation(
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)
@@ -377,12 +413,6 @@ class CartManager:
operations = []
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 not i.get('subevent'):
raise CartError(error_messages['subevent_required'])
@@ -390,6 +420,24 @@ class CartManager:
else:
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']]
variation = self._variations_cache[i['variation']] if i['variation'] is not None else None
voucher = None
@@ -445,7 +493,7 @@ class CartManager:
bop = self.AddOperation(
count=bundle.count, item=bitem, variation=bvar, price=bprice,
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)
bundled.append(bop)
@@ -454,7 +502,7 @@ class CartManager:
op = self.AddOperation(
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)
operations.append(op)
@@ -560,7 +608,7 @@ class CartManager:
op = self.AddOperation(
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)
operations.append(op)
@@ -634,7 +682,7 @@ class CartManager:
Q(voucher=voucher) & Q(event=self.event) &
Q(expires__gte=self.now_dt)
).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()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
@@ -686,6 +734,7 @@ class CartManager:
err = err or self._check_min_per_product()
self._operations.sort(key=lambda a: self.order[type(a)])
seats_seen = set()
for op in self._operations:
if isinstance(op, self.RemoveOperation):
@@ -699,6 +748,11 @@ class CartManager:
# Create a CartPosition for as much items as we can
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:
quota_available_count = min(requested_count, min(quotas_ok[q] for q in op.quotas))
@@ -744,12 +798,16 @@ class CartManager:
available_count = 0
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):
cp = CartPosition(
event=self.event, item=op.item, variation=op.variation,
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,
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:
scheme = PERSON_NAME_SCHEMES.get(self.event.settings.name_scheme)
@@ -788,7 +846,11 @@ class CartManager:
new_cart_positions.append(cp)
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.price = op.price.gross
try:
@@ -819,6 +881,9 @@ class CartManager:
# If any quotas are affected that are not unlimited, we lock
return True
if any(getattr(o, 'seat', False) for o in self._operations):
return True
return False
def commit(self):
@@ -902,23 +967,22 @@ def get_fees(event, request, total, invoice_address, provider):
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',
invoice_address: int=None, widget_data=None, sales_channel='web') -> None:
"""
Adds a list of items to a user's cart.
: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
:raises CartError: On any error that occured
"""
with language(locale):
event = Event.objects.get(id=event)
ia = False
if invoice_address:
try:
ia = InvoiceAddress.objects.get(pk=invoice_address)
with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist:
pass
@@ -934,8 +998,8 @@ def add_items_to_cart(self, event: int, items: List[dict], cart_id: str=None, lo
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, 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:
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
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.
:param event: The event ID in question
@@ -943,7 +1007,6 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
@@ -955,15 +1018,14 @@ def remove_cart_position(self, event: int, position: int, cart_id: str=None, loc
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def clear_cart(self, event: Event, cart_id: str=None, locale='en') -> None:
"""
Removes a list of items from a user's cart.
:param event: The event ID in question
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
try:
try:
cm = CartManager(event=event, cart_id=cart_id)
@@ -975,8 +1037,8 @@ def clear_cart(self, event: int, cart_id: str=None, locale='en') -> None:
raise CartError(error_messages['busy'])
@app.task(base=ProfiledTask, 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',
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(CartError,))
def set_cart_addons(self, event: Event, addons: List[dict], cart_id: str=None, locale='en',
invoice_address: int=None, sales_channel='web') -> None:
"""
Removes a list of items from a user's cart.
@@ -985,12 +1047,11 @@ def set_cart_addons(self, event: int, addons: List[dict], cart_id: str=None, loc
:param session: Session ID of a guest
"""
with language(locale):
event = Event.objects.get(id=event)
ia = False
if invoice_address:
try:
ia = InvoiceAddress.objects.get(pk=invoice_address)
with scopes_disabled():
ia = InvoiceAddress.objects.get(pk=invoice_address)
except InvoiceAddress.DoesNotExist:
pass
try:
+4
View File
@@ -2,6 +2,7 @@ from datetime import timedelta
from django.dispatch import receiver
from django.utils.timezone import now
from django_scopes import scopes_disabled
from pretix.base.models import CachedCombinedTicket, CachedTicket
@@ -10,6 +11,7 @@ from ..signals import periodic_task
@receiver(signal=periodic_task)
@scopes_disabled()
def clean_cart_positions(sender, **kwargs):
for cp in CartPosition.objects.filter(expires__lt=now() - timedelta(days=14), addon_to__isnull=False):
cp.delete()
@@ -20,12 +22,14 @@ def clean_cart_positions(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_files(sender, **kwargs):
for cf in CachedFile.objects.filter(expires__isnull=False, expires__lt=now()):
cf.delete()
@receiver(signal=periodic_task)
@scopes_disabled()
def clean_cached_tickets(sender, **kwargs):
for cf in CachedTicket.objects.filter(created__lte=now() - timedelta(days=30)):
cf.delete()
+15 -6
View File
@@ -2,24 +2,33 @@ from typing import Any, Dict
from django.core.files.base import ContentFile
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.services.tasks import ProfiledTask
from pretix.base.services.tasks import ProfiledEventTask
from pretix.base.signals import register_data_exporters
from pretix.celery_app import app
@app.task(base=ProfiledTask)
def export(event: str, fileid: str, provider: str, form_data: Dict[str, Any]) -> None:
event = Event.objects.get(id=event)
class ExportError(LazyLocaleException):
pass
@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)
with language(event.settings.locale), override(event.settings.timezone):
responses = register_data_exporters.send(event)
for receiver, response in responses:
ex = response(event)
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.save()
return file.pk
+15 -11
View File
@@ -15,6 +15,7 @@ from django.utils import timezone
from django.utils.timezone import now
from django.utils.translation import pgettext, ugettext as _
from django_countries.fields import Country
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
@@ -244,20 +245,23 @@ def generate_invoice(order: Order, trigger_pdf=True):
@app.task(base=TransactionAwareTask)
def invoice_pdf_task(invoice: int):
i = Invoice.objects.get(pk=invoice)
if i.shredded:
return None
if i.file:
i.file.delete()
with language(i.locale):
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
i.file.save(fname, ContentFile(fcontent))
i.save()
return i.file.name
with scopes_disabled():
i = Invoice.objects.get(pk=invoice)
with scope(organizer=i.order.event.organizer):
if i.shredded:
return None
if i.file:
i.file.delete()
with language(i.locale):
fname, ftype, fcontent = i.event.invoice_renderer.generate(i)
i.file.save(fname, ContentFile(fcontent))
i.save()
return i.file.name
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 True
+134 -90
View File
@@ -1,5 +1,6 @@
import logging
import smtplib
import warnings
from email.utils import formataddr
from typing import Any, Dict, List, Union
@@ -9,11 +10,14 @@ from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import get_template
from django.utils.translation import ugettext as _
from django_scopes import scope, scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix.base.email import ClassicMailRenderer
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.tasks import TransactionAwareTask
from pretix.base.services.tickets import get_tickets_for_order
@@ -38,8 +42,8 @@ class SendMailException(Exception):
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None,
attach_tickets=False):
order: Order=None, position: OrderPosition=None, headers: dict=None, sender: str=None,
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.
@@ -60,6 +64,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
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 locale: The locale to be used while evaluating the subject and the template
@@ -100,7 +107,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
subject = str(subject).format_map(context)
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
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:
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
@@ -111,7 +119,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
if event:
renderer = event.get_html_mail_renderer()
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'):
headers['Reply-To'] = event.settings.contact_mail
@@ -130,9 +139,26 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
body_plain += signature
body_plain += "\r\n\r\n-- \r\n"
if order:
if order.testmode:
subject = "[TESTMODE] " + subject
if order and order.testmode:
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 += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
@@ -141,16 +167,24 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
"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', kwargs={
order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret
'secret': order.secret,
'hash': order.email_confirm_hash()
}
)
)
body_plain += "\r\n"
try:
body_html = renderer.render(content_plain, signature, str(subject), order)
try:
body_html = renderer.render(content_plain, signature, str(subject), order, position)
except TypeError:
# 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:
logger.exception('Could not render HTML body')
body_html = None
@@ -164,8 +198,9 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
sender=sender,
event=event.id if event else None,
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,
position=position.pk if position else None,
attach_tickets=attach_tickets
)
@@ -180,98 +215,107 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
@app.task(base=TransactionAwareTask, bind=True)
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,
order: int=None, attach_tickets=False) -> bool:
event: int=None, position: int=None, headers: dict=None, bcc: List[str]=None,
invoices: List[int]=None, order: int=None, attach_tickets=False) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
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:
event = Event.objects.get(id=event)
with scopes_disabled():
event = Event.objects.get(id=event)
backend = event.get_mail_backend()
cm = lambda: scope(organizer=event.organizer) # noqa
else:
backend = get_connection(fail_silently=False)
cm = lambda: scopes_disabled() # noqa
if event:
if order:
try:
order = event.orders.get(pk=order)
except Order.DoesNotExist:
order = None
else:
if attach_tickets:
args = []
attach_size = 0
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': [],
}
with cm():
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 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:
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')
email = email_filter.send_chained(event, 'message', message=email, order=order)
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'SMTP code {}'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
'recipient': '',
'invoices': [],
}
)
try:
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')
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
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))
if order:
order.log_action(
'pretix.event.order.email.error',
data={
'subject': 'SMTP code {}'.format(e.smtp_code),
'message': e.smtp_error.decode() if isinstance(e.smtp_error, bytes) else str(e.smtp_error),
'recipient': '',
'invoices': [],
}
)
raise SendMailException('Failed to send an email to {}.'.format(to))
except Exception as e:
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):
+21 -10
View File
@@ -1,5 +1,6 @@
from django.conf import settings
from django.template.loader import get_template
from django_scopes import scope, scopes_disabled
from inlinestyler.utils import inline_css
from pretix.base.i18n import language
@@ -12,6 +13,7 @@ from pretix.helpers.urls import build_absolute_uri
@app.task(base=TransactionAwareTask)
@scopes_disabled()
def notify(logentry_id: int):
logentry = LogEntry.all.get(id=logentry_id)
if not logentry.event:
@@ -66,17 +68,22 @@ def notify(logentry_id: int):
@app.task(base=ProfiledTask)
def send_notification(logentry_id: int, action_type: str, user_id: int, method: str):
logentry = LogEntry.all.get(id=logentry_id)
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
if logentry.event:
sm = lambda: scope(organizer=logentry.event.organizer) # noqa
else:
sm = lambda: scopes_disabled() # noqa
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):
notification = notification_type.build_notification(logentry)
with language(user.locale):
notification = notification_type.build_notification(logentry)
if method == "mail":
send_notification_mail(notification, user)
if method == "mail":
send_notification_mail(notification, 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={
'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,
'html': body_html,
'sender': settings.MAIL_FROM,
+236 -68
View File
@@ -16,6 +16,7 @@ from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _
from django_scopes import scopes_disabled
from pretix.api.models import OAuthApplication
from pretix.base.i18n import (
@@ -23,7 +24,7 @@ from pretix.base.i18n import (
)
from pretix.base.models import (
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.items import ItemBundle
@@ -42,11 +43,12 @@ from pretix.base.services.invoices import (
from pretix.base.services.locking import LockTimeoutException, NoLockManager
from pretix.base.services.mail import SendMailException
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 (
allow_ticket_download, order_approved, order_canceled, order_changed,
order_denied, order_expired, order_fee_calculation, order_placed,
periodic_task,
periodic_task, validate_order,
)
from pretix.celery_app import app
from pretix.helpers.models import modelcopy
@@ -80,6 +82,8 @@ error_messages = {
'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 '
'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__)
@@ -222,9 +226,10 @@ def approve_order(order, user=None, send_mail: bool=True, auth=None, force=False
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires),
'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,
'secret': order.secret
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
@@ -282,9 +287,10 @@ def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires),
'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,
'secret': order.secret
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'comment': comment,
'invoice_name': invoice_name,
@@ -375,9 +381,10 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device
email_context = {
'event': order.event.name,
'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,
'secret': order.secret
'secret': order.secret,
'hash': order.email_confirm_hash()
})
}
with language(order.locale):
@@ -423,6 +430,7 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
products_seen = Counter()
changed_prices = {}
deleted_positions = set()
seats_seen = set()
def delete(cp):
# Delete a cart position, including parents and children, if applicable
@@ -485,17 +493,32 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
delete(cp)
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:
delete(cp)
err = err or error_messages['voucher_required']
break
if cp.item.hide_without_voucher and (cp.voucher is None or cp.voucher.item is None
or cp.voucher.item.pk != cp.item.pk):
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.pk, cp.variation.pk)):
delete(cp)
cp.delete()
err = error_messages['voucher_required']
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:
# Other checks are not necessary
continue
@@ -531,7 +554,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
continue
if price.gross != cp.price and not (cp.item.free_price and cp.price > price.gross):
positions[i] = cp
cp.price = price.gross
cp.includes_tax = bool(price.rate)
cp.save()
@@ -555,7 +577,6 @@ def _check_positions(event: Event, now_dt: datetime, positions: List[CartPositio
break
if quota_ok:
positions[i] = cp
cp.expires = now_dt + timedelta(
minutes=event.settings.get('reservation_time', as_type=int))
cp.save()
@@ -644,10 +665,77 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
return order, p
def _perform_order(event: str, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None, sales_channel: str='web'):
def _order_placed_email(event: Event, order: Order, pprov: BasePaymentProvider, email_template, log_entry: str,
invoice):
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:
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:
pprov = event.get_payment_providers().get(payment_provider)
if not pprov:
@@ -661,22 +749,35 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
addr = None
if address is not None:
try:
addr = InvoiceAddress.objects.get(pk=address)
with scopes_disabled():
addr = InvoiceAddress.objects.get(pk=address)
except InvoiceAddress.DoesNotExist:
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
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,
# creating this order shouldn't be prone to any race conditions and we don't need to lock the event.
locked = True
lockfn = event.lock
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:
raise OrderError(error_messages['empty'])
if len(position_ids) != len(positions):
@@ -705,54 +806,32 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
if order.require_approval:
email_template = event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
email_attendees = False
elif free_order_flow:
email_template = event.settings.mail_text_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:
email_template = event.settings.mail_text_order_placed
log_entry = 'pretix.event.order.email.order_placed'
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_attendees = event.settings.mail_send_order_placed_attendee
email_attendees_template = event.settings.mail_text_order_placed_attendee
if pprov:
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', 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')
_order_placed_email(event, order, pprov, email_template, log_entry, invoice)
if email_attendees:
for p in order.positions.all():
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)
return order.id
@receiver(signal=periodic_task)
@scopes_disabled()
def expire_orders(sender, **kwargs):
eventcache = {}
@@ -767,6 +846,7 @@ def expire_orders(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def send_expiry_warnings(sender, **kwargs):
eventcache = {}
today = now().replace(hour=0, minute=0, second=0)
@@ -800,9 +880,10 @@ def send_expiry_warnings(sender, **kwargs):
email_template = eventsettings.mail_text_order_expire_warning
email_context = {
'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,
'secret': o.secret
'secret': o.secret,
'hash': o.email_confirm_hash()
}),
'expire_date': date_format(o.expires.astimezone(tz), 'SHORT_DATE_FORMAT'),
'invoice_name': invoice_name,
@@ -823,6 +904,7 @@ def send_expiry_warnings(sender, **kwargs):
@receiver(signal=periodic_task)
@scopes_disabled()
def send_download_reminders(sender, **kwargs):
today = now().replace(hour=0, minute=0, second=0, microsecond=0)
@@ -851,9 +933,10 @@ def send_download_reminders(sender, **kwargs):
email_template = e.settings.mail_text_download_reminder
email_context = {
'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,
'secret': o.secret
'secret': o.secret,
'hash': o.email_confirm_hash()
}),
}
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
@@ -866,6 +949,31 @@ def send_download_reminders(sender, **kwargs):
except SendMailException:
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')
class OrderChangeManager:
error_messages = {
@@ -880,12 +988,17 @@ class OrderChangeManager:
'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.'),
'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'))
SubeventOperation = namedtuple('SubeventOperation', ('position', 'subevent'))
SeatOperation = namedtuple('SubeventOperation', ('position', 'seat'))
PriceOperation = namedtuple('PriceOperation', ('position', 'price'))
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',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
@@ -898,6 +1011,7 @@ class OrderChangeManager:
self._committed = False
self._totaldiff = 0
self._quotadiff = Counter()
self._seatdiff = Counter()
self._operations = []
self.notify = notify
self._invoice_dirty = False
@@ -915,6 +1029,13 @@ class OrderChangeManager:
self._quotadiff.subtract(position.quotas)
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):
price = get_price(position.item, position.variation, voucher=position.voucher, subevent=subevent,
invoice_address=self._invoice_address)
@@ -970,12 +1091,14 @@ class OrderChangeManager:
self._totaldiff += -position.price
self._quotadiff.subtract(position.quotas)
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'):
self._invoice_dirty = True
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:
price = get_price(item, variation, subevent=subevent, invoice_address=self._invoice_address)
else:
@@ -994,6 +1117,14 @@ class OrderChangeManager:
if self.order.event.has_subevents and not subevent:
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)
if variation else item.quotas.filter(subevent=subevent))
if not new_quotas:
@@ -1004,7 +1135,9 @@ class OrderChangeManager:
self._totaldiff += price.gross
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):
if self.order.event.settings.invoice_include_free or position.price != Decimal('0.00'):
@@ -1012,6 +1145,26 @@ class OrderChangeManager:
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):
for quota, diff in self._quotadiff.items():
if diff <= 0:
@@ -1078,7 +1231,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['paid_to_free_exceeded'])
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 = []
for op in self._operations:
@@ -1098,6 +1251,17 @@ class OrderChangeManager:
op.position.variation = op.variation
op.position._calculate_tax()
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):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
'position': op.position.pk,
@@ -1151,7 +1315,7 @@ class OrderChangeManager:
item=op.item, variation=op.variation, addon_to=op.addon_to,
price=op.price.gross, order=self.order, tax_rate=op.price.rate,
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
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
@@ -1162,6 +1326,7 @@ class OrderChangeManager:
'price': op.price.gross,
'positionid': pos.positionid,
'subevent': op.subevent.pk if op.subevent else None,
'seat': op.seat.pk if op.seat else None,
})
elif isinstance(op, self.SplitOperation):
split_positions.append(op.position)
@@ -1350,9 +1515,10 @@ class OrderChangeManager:
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={
'url': build_absolute_uri(self.order.event, 'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret
'secret': order.secret,
'hash': order.email_confirm_hash()
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
@@ -1385,6 +1551,7 @@ class OrderChangeManager:
raise OrderError(self.error_messages['not_pending_or_paid'])
if check_quotas:
self._check_quotas()
self._check_seats()
self._check_complete_cancel()
self._perform_operations()
self._recalculate_total_and_payment_fee()
@@ -1418,8 +1585,8 @@ class OrderChangeManager:
return pprov
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def perform_order(self, event: str, payment_provider: str, positions: List[str],
@app.task(base=ProfiledEventTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
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,
sales_channel: str='web'):
with language(locale):
@@ -1434,6 +1601,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,))
@scopes_disabled()
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):
try:
+25 -17
View File
@@ -1,11 +1,12 @@
from datetime import timedelta
from django.db import models
from django.db.models import F, Max, OuterRef, Q, Subquery
from django.conf import settings
from django.db.models import Max, Q
from django.dispatch import receiver
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 ..signals import periodic_task
@@ -17,20 +18,27 @@ def build_all_quota_caches(sender, **kwargs):
@app.task
@scopes_disabled()
def refresh_quota_caches():
last_activity = LogEntry.objects.filter(
event=OuterRef('event_id'),
# Active events
active = LogEntry.objects.using(settings.DATABASE_REPLICA).filter(
datetime__gt=now() - timedelta(days=7)
).order_by().values('event').annotate(
m=Max('datetime')
).values(
'm'
last_activity=Max('datetime')
)
quotas = Quota.objects.annotate(
last_activity=Subquery(last_activity, output_field=models.DateTimeField())
).filter(
Q(cached_availability_time__isnull=True) |
Q(cached_availability_time__lt=F('last_activity')) |
Q(cached_availability_time__lt=now() - timedelta(hours=2), last_activity__gt=now() - timedelta(days=7))
).select_related('subevent')
for q in quotas:
q.availability()
for a in active:
try:
e = Event.objects.using(settings.DATABASE_REPLICA).get(pk=a['event'])
except Event.DoesNotExist:
continue
quotas = e.quotas.filter(
Q(cached_availability_time__isnull=True) |
Q(cached_availability_time__lt=a['last_activity']) |
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 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.celery_app import app
@app.task(base=ProfiledTask)
def export(event: str, shredders: List[str]) -> None:
event = Event.objects.get(id=event)
@app.task(base=ProfiledEventTask)
def export(event: Event, shredders: List[str]) -> None:
known_shredders = event.get_data_shredders()
with NamedTemporaryFile() as rawfile:
@@ -63,9 +62,8 @@ def export(event: str, shredders: List[str]) -> None:
return cf.pk
@app.task(base=ProfiledTask, throws=(ShredError,))
def shred(event: str, fileid: str, confirm_code: str) -> None:
event = Event.objects.get(id=event)
@app.task(base=ProfiledEventTask, throws=(ShredError,))
def shred(event: Event, fileid: str, confirm_code: str) -> None:
known_shredders = event.get_data_shredders()
try:
cf = CachedFile.objects.get(pk=fileid)
+31
View File
@@ -14,10 +14,12 @@ import time
from django.conf import settings
from django.db import transaction
from django_scopes import scope, scopes_disabled
from pretix.base.metrics import (
pretix_task_duration_seconds, pretix_task_runs_total,
)
from pretix.base.models import Event
from pretix.celery_app import app
@@ -61,6 +63,35 @@ class ProfiledTask(app.Task):
return super().on_success(retval, task_id, args, kwargs)
class EventTask(app.Task):
def __call__(self, *args, **kwargs):
if 'event_id' in kwargs:
event_id = kwargs.get('event_id')
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
del kwargs['event_id']
kwargs['event'] = event
elif 'event' in kwargs:
event_id = kwargs.get('event')
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
kwargs['event'] = event
else:
args = list(args)
event_id = args[0]
with scopes_disabled():
event = Event.objects.select_related('organizer').get(pk=event_id)
args[0] = event
with scope(organizer=event.organizer):
ret = super().__call__(*args, **kwargs)
return ret
class ProfiledEventTask(ProfiledTask, EventTask):
pass
class TransactionAwareTask(ProfiledTask):
"""
Task class which is aware of django db transactions and only executes tasks
+21 -13
View File
@@ -4,13 +4,14 @@ import os
from django.core.files.base import ContentFile
from django.utils.timezone import now
from django.utils.translation import ugettext as _
from django_scopes import scopes_disabled
from pretix.base.i18n import language
from pretix.base.models import (
CachedCombinedTicket, CachedTicket, Event, InvoiceAddress, Order,
OrderPosition,
)
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import EventTask, ProfiledTask
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import allow_ticket_download, register_ticket_outputs
from pretix.celery_app import app
@@ -57,10 +58,11 @@ def generate_order(order: int, provider: str):
@app.task(base=ProfiledTask)
def generate(model: str, pk: int, provider: str):
if model == 'order':
return generate_order(pk, provider)
elif model == 'orderposition':
return generate_orderposition(pk, provider)
with scopes_disabled():
if model == 'order':
return generate_order(pk, provider)
elif model == 'orderposition':
return generate_orderposition(pk, provider)
class DummyRollbackException(Exception):
@@ -96,7 +98,7 @@ def preview(event: int, provider: str):
return prov.generate(p)
def get_tickets_for_order(order):
def get_tickets_for_order(order, base_position=None):
can_download = all([r for rr, r in allow_ticket_download.send(order.event, order=order)])
if not can_download:
return []
@@ -111,13 +113,20 @@ def get_tickets_for_order(order):
tickets = []
positions = list(order.positions_with_tickets)
if base_position:
# Only the given position and its children
positions = [
p for p in positions if p.pk == base_position.pk or p.addon_to_id == base_position.pk
]
for p in providers:
if not p.is_enabled:
continue
if p.multi_download_enabled:
if p.multi_download_enabled and not base_position:
try:
if len(list(order.positions_with_tickets)) == 0:
if len(positions) == 0:
continue
ct = CachedCombinedTicket.objects.filter(
order=order, provider=p.identifier, file__isnull=False
@@ -136,7 +145,7 @@ def get_tickets_for_order(order):
except:
logger.exception('Failed to generate ticket.')
else:
for pos in order.positions_with_tickets:
for pos in positions:
try:
ct = CachedTicket.objects.filter(
order_position=pos, provider=p.identifier, file__isnull=False
@@ -145,7 +154,7 @@ def get_tickets_for_order(order):
retval = generate_orderposition(pos.pk, p.identifier)
if not retval:
continue
ct = CachedCombinedTicket.objects.get(pk=retval)
ct = CachedTicket.objects.get(pk=retval)
tickets.append((
"{}-{}-{}-{}{}".format(
order.event.slug.upper(), order.code, pos.positionid, ct.provider, ct.extension,
@@ -158,9 +167,8 @@ def get_tickets_for_order(order):
return tickets
@app.task(base=ProfiledTask)
def invalidate_cache(event: int, item: int=None, provider: str=None, order: int=None, **kwargs):
event = Event.objects.get(id=event)
@app.task(base=EventTask)
def invalidate_cache(event: Event, item: int=None, provider: str=None, order: int=None, **kwargs):
qs = CachedTicket.objects.filter(order_position__order__event=event)
qsc = CachedCombinedTicket.objects.filter(order__event=event)
+2
View File
@@ -6,6 +6,7 @@ import requests
from django.dispatch import receiver
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _, ugettext_noop
from django_scopes import scopes_disabled
from i18nfield.strings import LazyI18nString
from pretix import __version__
@@ -29,6 +30,7 @@ def run_update_check(sender, **kwargs):
@app.task
@scopes_disabled()
def update_check():
gs = GlobalSettingsObject()
+5 -4
View File
@@ -1,17 +1,17 @@
import sys
from django.dispatch import receiver
from django_scopes import scopes_disabled
from pretix.base.models import Event, User, WaitingListEntry
from pretix.base.models.waitinglist import WaitingListException
from pretix.base.services.tasks import ProfiledTask
from pretix.base.services.tasks import EventTask
from pretix.base.signals import periodic_task
from pretix.celery_app import app
@app.task(base=ProfiledTask)
def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None):
event = Event.objects.get(id=event_id)
@app.task(base=EventTask)
def assign_automatically(event: Event, user_id: int=None, subevent_id: int=None):
if user_id:
user = User.objects.get(id=user_id)
else:
@@ -69,6 +69,7 @@ def assign_automatically(event_id: int, user_id: int=None, subevent_id: int=None
@receiver(signal=periodic_task)
@scopes_disabled()
def process_waitinglist(sender, **kwargs):
qs = Event.objects.filter(
live=True
+72
View File
@@ -145,6 +145,10 @@ DEFAULTS = {
'default': 'False',
'type': str
},
'invoice_generate_sales_channels': {
'default': json.dumps(['web']),
'type': list
},
'invoice_address_from': {
'default': '',
'type': str
@@ -297,6 +301,10 @@ DEFAULTS = {
'default': settings.MAIL_FROM,
'type': str
},
'mail_from_name': {
'default': None,
'type': str
},
'mail_text_signature': {
'type': LazyI18nString,
'default': ""
@@ -323,6 +331,18 @@ The list is as follows:
{orders}
Best regards,
Your {event} team"""))
},
'mail_text_order_free_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
you have been registered for {event} successfully.
You can view the details and status of your ticket here:
{url}
Best regards,
Your {event} team"""))
},
@@ -339,6 +359,10 @@ You can change your order details and view the status of your order at
Best regards,
Your {event} team"""))
},
'mail_send_order_free_attendee': {
'type': bool,
'default': 'False'
},
'mail_text_order_placed_require_approval': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
@@ -365,6 +389,22 @@ of {total_with_currency}. Please complete your payment before {date}.
You can change your order details and view the status of your order at
{url}
Best regards,
Your {event} team"""))
},
'mail_send_order_placed_attendee': {
'type': bool,
'default': 'False'
},
'mail_text_order_placed_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
a ticket for {event} has been ordered for you.
You can view the details and status of your ticket here:
{url}
Best regards,
Your {event} team"""))
},
@@ -391,6 +431,22 @@ we successfully received your payment for {event}. Thank you!
You can change your order details and view the status of your order at
{url}
Best regards,
Your {event} team"""))
},
'mail_send_order_paid_attendee': {
'type': bool,
'default': 'False'
},
'mail_text_order_paid_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
a ticket for {event} that has been ordered for you is now paid.
You can view the details and status of your ticket here:
{url}
Best regards,
Your {event} team"""))
},
@@ -492,6 +548,22 @@ Your {event} team"""))
'type': int,
'default': None
},
'mail_send_download_reminder_attendee': {
'type': bool,
'default': 'False'
},
'mail_text_download_reminder_attendee': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello {attendee_name},
you are registered for {event}.
If you did not do so already, you can download your ticket here:
{url}
Best regards,
Your {event} team"""))
},
'mail_text_download_reminder': {
'type': LazyI18nString,
'default': LazyI18nString.from_gettext(ugettext_noop("""Hello,
+39
View File
@@ -240,6 +240,19 @@ subclass of pretix.base.exporter.BaseExporter
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
validate_order = EventPluginSignal(
providing_args=["payment_provider", "positions", "email", "locale", "invoice_address",
"meta_info"]
)
"""
This signal is sent out when the user tries to confirm the order, before we actually create
the order. It allows you to inspect the cart positions. Your return value will be ignored,
but you can raise an OrderError with an appropriate exception message if you like to block
the order. We strongly discourage making changes to the order here.
As with all event-plugin signals, the ``sender`` keyword argument will contain the event.
"""
validate_cart = EventPluginSignal(
providing_args=["positions"]
)
@@ -502,3 +515,29 @@ dictionaries as values that contain keys like in the following example::
The evaluate member will be called with the order position, order and event as arguments. The event might
also be a subevent, if applicable.
"""
timeline_events = EventPluginSignal()
"""
This signal is sent out to collect events for the time line shown on event dashboards. You are passed
a ``subevent`` argument which might be none and you are expected to return a list of instances of
``pretix.base.timeline.TimelineEvent``, which is a ``namedtuple`` with the fields ``event``, ``subevent``,
``datetime``, ``description`` and ``edit_url``.
"""
quota_availability = EventPluginSignal(
providing_args=['quota', 'result', 'count_waitinglist']
)
"""
This signal allows you to modify the availability of a quota. You are passed the ``quota`` and an
``availability`` result calculated by pretix code or other plugins. ``availability`` is a tuple
with the first entry being one of the ``Quota.AVAILABILITY_*`` constants and the second entry being
the number of available tickets (or ``None`` for unlimited). You are expected to return a value
of the same time. The parameter ``count_waitinglists`` specifies whether waiting lists should be taken
into account.
**Warning: Use this signal with great caution, it allows you to screw up the performance of the
system really bad.** Also, keep in mind that your response is subject to caching and out-of-date
quotas might be used for display (not for actual order processing).
"""
@@ -23,13 +23,23 @@
<table cellpadding="20"><tr><td>
<![endif]-->
<div class="content">
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order" order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
{% if position %}
{% trans "You are receiving this email because someone signed you up for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order.position" order=order.code secret=position.web_secret position=position.positionid %}">
{% trans "View registration details" %}
</a>
{% else %}
{% trans "You are receiving this email because you placed an order for the following event:" %}<br>
<strong>{% trans "Event:" %}</strong> {{ event.name }}<br>
<strong>{% trans "Order code:" %}</strong> {{ order.code }}<br>
<strong>{% trans "Order date:" %}</strong> {{ order.datetime|date:"SHORT_DATE_FORMAT" }}<br>
<a href="{% abseventurl event "presale:event.order.open" hash=order.email_confirm_hash order=order.code secret=order.secret %}">
{% trans "View order details" %}
</a>
{% endif %}
</div>
<!--[if gte mso 9]>
</td></tr></table>
+1 -1
View File
@@ -47,7 +47,7 @@ ALLOWED_TAGS = [
]
ALLOWED_ATTRIBUTES = {
'a': ['href', 'title'],
'a': ['href', 'title', 'class'],
'abbr': ['title'],
'acronym': ['title'],
'table': ['width'],
+200
View File
@@ -0,0 +1,200 @@
from collections import namedtuple
from datetime import datetime, time, timedelta
from django.db.models import Q
from django.urls import reverse
from django.utils.timezone import make_aware
from django.utils.translation import pgettext_lazy
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.signals import timeline_events
TimelineEvent = namedtuple('TimelineEvent', ('event', 'subevent', 'datetime', 'description', 'edit_url'))
def timeline_for_event(event, subevent=None):
tl = []
ev = subevent or event
if subevent:
ev_edit_url = reverse(
'control:event.subevent', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'subevent': subevent.pk
}
)
else:
ev_edit_url = reverse(
'control:event.settings', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
}
)
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=ev.date_from,
description=pgettext_lazy('timeline', 'Your event starts'),
edit_url=ev_edit_url
))
if ev.date_to:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=ev.date_to,
description=pgettext_lazy('timeline', 'Your event ends'),
edit_url=ev_edit_url
))
if ev.date_admission:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=ev.date_admission,
description=pgettext_lazy('timeline', 'Admissions for your event start'),
edit_url=ev_edit_url
))
if ev.presale_start:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=ev.presale_start,
description=pgettext_lazy('timeline', 'Start of ticket sales'),
edit_url=ev_edit_url
))
if ev.presale_end:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=ev.presale_end,
description=pgettext_lazy('timeline', 'End of ticket sales'),
edit_url=ev_edit_url
))
rd = event.settings.get('last_order_modification_date', as_type=RelativeDateWrapper)
if rd:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Customers can no longer modify their orders'),
edit_url=ev_edit_url
))
rd = event.settings.get('payment_term_last', as_type=RelativeDateWrapper)
if rd:
d = make_aware(datetime.combine(
rd.date(ev),
time(hour=23, minute=59, second=59)
), event.timezone)
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=d,
description=pgettext_lazy('timeline', 'No more payments can be completed'),
edit_url=reverse('control:event.settings.payment', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
})
))
rd = event.settings.get('ticket_download_date', as_type=RelativeDateWrapper)
if rd and event.settings.ticket_download:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Tickets can be downloaded'),
edit_url=reverse('control:event.settings.tickets', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
})
))
rd = event.settings.get('cancel_allow_user_until', as_type=RelativeDateWrapper)
if rd and event.settings.cancel_allow_user:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Customers can no longer cancel free or unpaid orders'),
edit_url=reverse('control:event.settings.tickets', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
})
))
rd = event.settings.get('cancel_allow_user_paid_until', as_type=RelativeDateWrapper)
if rd and event.settings.cancel_allow_user_paid:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=rd.datetime(ev),
description=pgettext_lazy('timeline', 'Customers can no longer cancel paid orders'),
edit_url=reverse('control:event.settings.tickets', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
})
))
if not event.has_subevents:
days = event.settings.get('mail_days_download_reminder', as_type=int)
if days is not None:
reminder_date = (ev.date_from - timedelta(days=days)).replace(hour=0, minute=0, second=0, microsecond=0)
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=reminder_date,
description=pgettext_lazy('timeline', 'Download reminders are being sent out'),
edit_url=reverse('control:event.settings.mail', kwargs={
'event': event.slug,
'organizer': event.organizer.slug
})
))
for p in event.items.filter(Q(available_from__isnull=False) | Q(available_until__isnull=False)):
if p.available_from:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=p.available_from,
description=pgettext_lazy('timeline', 'Product "{name}" becomes available').format(name=str(p)),
edit_url=reverse('control:event.item', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'item': p.pk,
})
))
if p.available_until:
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=p.available_until,
description=pgettext_lazy('timeline', 'Product "{name}" becomes unavailable').format(name=str(p)),
edit_url=reverse('control:event.item', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'item': p.pk,
})
))
pprovs = event.get_payment_providers()
# This is a special case, depending on payment providers not overriding BasePaymentProvider by too much, but it's
# preferrable to having all plugins implement this spearately.
for pprov in pprovs.values():
if not pprov.settings.get('_enabled', as_type=bool):
continue
availability_date = pprov.settings.get('_availability_date', as_type=RelativeDateWrapper)
if availability_date:
d = make_aware(datetime.combine(
availability_date.date(ev),
time(hour=23, minute=59, second=59)
), event.timezone)
tl.append(TimelineEvent(
event=event, subevent=subevent,
datetime=d,
description=pgettext_lazy('timeline', 'Payment provider "{name}" can no longer be selected').format(
name=str(pprov.verbose_name)
),
edit_url=reverse('control:event.settings.payment.provider', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'provider': pprov.identifier,
})
))
for recv, resp in timeline_events.send(sender=event, subevent=subevent):
tl += resp
return sorted(tl, key=lambda e: e.datetime)
+2
View File
@@ -3,6 +3,7 @@ import hmac
from django.conf import settings
from django.http import HttpResponse
from django_scopes import scopes_disabled
from .. import metrics
@@ -15,6 +16,7 @@ def unauthed_response():
return response
@scopes_disabled()
def serve_metrics(request):
if not settings.METRICS_ENABLED:
return unauthed_response()
+5 -4
View File
@@ -4,7 +4,7 @@ from decimal import Decimal
from django import forms
from django.core.files.uploadedfile import UploadedFile
from django.db.models import Prefetch
from django.db.models import Prefetch, QuerySet
from django.utils.functional import cached_property
from pretix.base.forms.questions import (
@@ -89,19 +89,20 @@ class BaseQuestionsViewMixin:
elif k == 'attendee_email':
form.pos.attendee_email = v if v != '' else None
form.pos.save()
elif k.startswith('question_') and v is not None:
elif k.startswith('question_'):
field = form.fields[k]
if hasattr(field, 'answer'):
# We already have a cached answer object, so we don't
# have to create a new one
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False):
if v == '' or v is None or (isinstance(field, forms.FileField) and v is False) \
or (isinstance(v, QuerySet) and not v.exists()):
if field.answer.file:
field.answer.file.delete()
field.answer.delete()
else:
self._save_to_answer(field, field.answer, v)
field.answer.save()
elif v != '':
elif v != '' and v is not None:
answer = QuestionAnswer(
cartposition=(form.pos if isinstance(form.pos, CartPosition) else None),
orderposition=(form.pos if isinstance(form.pos, OrderPosition) else None),
+6 -4
View File
@@ -5,6 +5,7 @@ from django.conf import settings
from django.db.models import Q
from django.urls import Resolver404, get_script_prefix, resolve
from django.utils.translation import get_language
from django_scopes import scope
from pretix.base.models.auth import StaffSession
from pretix.base.settings import GlobalSettingsObject
@@ -53,10 +54,11 @@ def contextprocessor(request):
ctx['has_domain'] = request.event.organizer.domains.exists()
if not request.event.testmode:
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
if complain_testmode_orders is None:
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
with scope(organizer=request.organizer):
complain_testmode_orders = request.event.cache.get('complain_testmode_orders')
if complain_testmode_orders is None:
complain_testmode_orders = request.event.orders.filter(testmode=True).exists()
request.event.cache.set('complain_testmode_orders', complain_testmode_orders, 30)
ctx['complain_testmode_orders'] = complain_testmode_orders
else:
ctx['complain_testmode_orders'] = False
+4
View File
@@ -200,3 +200,7 @@ class SplitDateTimeField(forms.SplitDateTimeField):
result = datetime.datetime.combine(*data_list)
return from_current_timezone(result)
return None
class FontSelect(forms.RadioSelect):
option_template_name = 'pretixcontrol/font_option.html'
+7
View File
@@ -1,6 +1,9 @@
from django import forms
from django.urls import reverse
from django.utils.translation import pgettext_lazy
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from pretix.base.models.checkin import CheckinList
from pretix.control.forms.widgets import Select2
@@ -44,3 +47,7 @@ class CheckinListForm(forms.ModelForm):
'data-inverse-dependency': '<[name$=all_products]'
}),
}
field_classes = {
'limit_products': SafeModelMultipleChoiceField,
'subevent': SafeModelChoiceField,
}
+138 -17
View File
@@ -2,9 +2,10 @@ from django import forms
from django.conf import settings
from django.contrib.auth.hashers import check_password
from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.core.validators import RegexValidator, validate_email
from django.db.models import Q
from django.forms import formset_factory
from django.urls import reverse
from django.utils.html import escape
from django.utils.safestring import mark_safe
from django.utils.timezone import get_current_timezone_name
@@ -18,15 +19,17 @@ from i18nfield.forms import (
)
from pytz import common_timezones, timezone
from pretix.base.channels import get_all_sales_channels
from pretix.base.forms import I18nModelForm, PlaceholderValidator, SettingsForm
from pretix.base.models import Event, Organizer, TaxRule
from pretix.base.models.event import EventMetaValue, SubEvent
from pretix.base.reldate import RelativeDateField, RelativeDateTimeField
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.control.forms import (
ExtFileField, MultipleLanguagesWidget, SingleLanguageWidget, SlugWidget,
SplitDateTimeField, SplitDateTimePickerWidget,
ExtFileField, FontSelect, MultipleLanguagesWidget, SingleLanguageWidget,
SlugWidget, SplitDateTimeField, SplitDateTimePickerWidget,
)
from pretix.control.forms.widgets import Select2
from pretix.multidomain.urlreverse import build_absolute_uri
from pretix.plugins.banktransfer.payment import BankTransfer
from pretix.presale.style import get_fonts
@@ -51,16 +54,28 @@ class EventWizardFoundationForm(forms.Form):
def __init__(self, *args, **kwargs):
self.user = kwargs.pop('user')
self.session = kwargs.pop('session')
super().__init__(*args, **kwargs)
qs = Organizer.objects.all()
if not self.user.has_active_staff_session(self.session.session_key):
qs = qs.filter(
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
)
self.fields['organizer'] = forms.ModelChoiceField(
label=_("Organizer"),
queryset=Organizer.objects.filter(
id__in=self.user.teams.filter(can_create_events=True).values_list('organizer', flat=True)
queryset=qs,
widget=Select2(
attrs={
'data-model-select2': 'generic',
'data-select2-url': reverse('control:organizers.select2') + '?can_create=1',
'data-placeholder': _('Organizer')
}
),
widget=forms.RadioSelect,
empty_label=None,
required=True
)
self.fields['organizer'].widget.choices = self.fields['organizer'].choices
if len(self.fields['organizer'].choices) == 1:
self.fields['organizer'].initial = self.fields['organizer'].queryset.first()
@@ -116,6 +131,7 @@ class EventWizardBasicsForm(I18nModelForm):
self.locales = kwargs.get('locales')
self.has_subevents = kwargs.pop('has_subevents')
kwargs.pop('user')
kwargs.pop('session')
super().__init__(*args, **kwargs)
self.initial['timezone'] = get_current_timezone_name()
self.fields['locale'].choices = [(a, b) for a, b in settings.LANGUAGES if a in self.locales]
@@ -173,7 +189,9 @@ class EventChoiceField(forms.ModelChoiceField):
class EventWizardCopyForm(forms.Form):
@staticmethod
def copy_from_queryset(user):
def copy_from_queryset(user, session):
if user.has_active_staff_session(session.session_key):
return Event.objects.all()
return Event.objects.filter(
Q(organizer_id__in=user.teams.filter(
all_events=True, can_change_event_settings=True, can_change_items=True
@@ -185,16 +203,25 @@ class EventWizardCopyForm(forms.Form):
def __init__(self, *args, **kwargs):
kwargs.pop('organizer')
kwargs.pop('locales')
self.session = kwargs.pop('session')
kwargs.pop('has_subevents')
self.user = kwargs.pop('user')
super().__init__(*args, **kwargs)
self.fields['copy_from_event'] = EventChoiceField(
label=_("Copy configuration from"),
queryset=EventWizardCopyForm.copy_from_queryset(self.user),
widget=forms.RadioSelect,
queryset=EventWizardCopyForm.copy_from_queryset(self.user, self.session),
widget=Select2(
attrs={
'data-model-select2': 'event',
'data-select2-url': reverse('control:events.typeahead') + '?can_copy=1',
'data-placeholder': _('Do not copy')
}
),
empty_label=_('Do not copy'),
required=False
)
self.fields['copy_from_event'].widget.choices = self.fields['copy_from_event'].choices
class EventMetaValueForm(forms.ModelForm):
@@ -664,6 +691,13 @@ class InvoiceSettingsForm(SettingsForm):
),
help_text=_("Invoices will never be automatically generated for free orders.")
)
invoice_generate_sales_channels = forms.MultipleChoiceField(
label=_('Generate invoices for Sales channels'),
choices=[],
widget=forms.CheckboxSelectMultiple,
help_text=_("If you have enabled invoice generation in the previous setting, you can limit it here to specific "
"sales channels.")
)
invoice_attendee_name = forms.BooleanField(
label=_("Show attendee names on invoices"),
required=False
@@ -779,6 +813,16 @@ class InvoiceSettingsForm(SettingsForm):
self.fields['invoice_numbers_prefix'].widget.attrs['placeholder'] = event.slug.upper() + '-'
locale_names = dict(settings.LANGUAGES)
self.fields['invoice_language'].choices = [('__user__', _('The user\'s language'))] + [(a, locale_names[a]) for a in event.settings.locales]
self.fields['invoice_generate_sales_channels'].choices = (
(c.identifier, c.verbose_name) for c in get_all_sales_channels().values()
)
def multimail_validate(val):
s = val.split(',')
for part in s:
validate_email(part.strip())
return s
class MailSettingsForm(SettingsForm):
@@ -790,12 +834,20 @@ class MailSettingsForm(SettingsForm):
)
mail_from = forms.EmailField(
label=_("Sender address"),
help_text=_("Sender address for outgoing emails")
help_text=_("Sender address for outgoing emails"),
)
mail_bcc = forms.EmailField(
mail_from_name = forms.CharField(
label=_("Sender name"),
help_text=_("Sender name used in conjunction with the sender address for outgoing emails. "
"Defaults to your event name."),
required=False
)
mail_bcc = forms.CharField(
label=_("Bcc address"),
help_text=_("All emails will be sent to this address as a Bcc copy"),
required=False
validators=[multimail_validate],
required=False,
max_length=255
)
mail_text_signature = I18nFormField(
@@ -818,7 +870,7 @@ class MailSettingsForm(SettingsForm):
)
mail_text_order_placed = I18nFormField(
label=_("Text"),
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {total_with_currency}, {total}, {currency}, {date}, "
@@ -826,20 +878,62 @@ class MailSettingsForm(SettingsForm):
validators=[PlaceholderValidator(['{event}', '{total_with_currency}', '{total}', '{currency}', '{date}',
'{payment_info}', '{url}', '{invoice_name}', '{invoice_company}'])]
)
mail_send_order_placed_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_text_order_placed_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
)
mail_text_order_paid = I18nFormField(
label=_("Text"),
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}, {payment_info}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}', '{payment_info}'])]
)
mail_send_order_paid_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_text_order_paid_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
)
mail_text_order_free = I18nFormField(
label=_("Text"),
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {invoice_name}, {invoice_company}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{invoice_name}', '{invoice_company}'])]
)
mail_send_order_free_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_text_order_free_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}, {attendee_name}"),
validators=[PlaceholderValidator(['{event}', '{url}', '{attendee_name}'])],
)
mail_text_order_changed = I18nFormField(
label=_("Text"),
required=False,
@@ -899,12 +993,25 @@ class MailSettingsForm(SettingsForm):
'{invoice_name}', '{invoice_company}'])]
)
mail_text_download_reminder = I18nFormField(
label=_("Text"),
label=_("Text sent to order contact address"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {event}, {url}"),
validators=[PlaceholderValidator(['{event}', '{url}'])]
)
mail_send_download_reminder_attendee = forms.BooleanField(
label=_("Send an email to attendees"),
help_text=_('If the order contains attendees with email addresses different from the person who orders the '
'tickets, the following email will be sent out to the attendees.'),
required=False,
)
mail_text_download_reminder_attendee = I18nFormField(
label=_("Text sent to attendees"),
required=False,
widget=I18nTextarea,
help_text=_("Available placeholders: {attendee_name}, {event}, {url}"),
validators=[PlaceholderValidator(['{attendee_name}', '{event}', '{url}'])]
)
mail_days_download_reminder = forms.IntegerField(
label=_("Number of days"),
required=False,
@@ -985,13 +1092,26 @@ class MailSettingsForm(SettingsForm):
(r.identifier, r.verbose_name) for r in event.get_html_mail_renderers().values()
]
keys = list(event.meta_data.keys())
for k, v in self.fields.items():
name_scheme = PERSON_NAME_SCHEMES[event.settings.name_scheme]
for k, v in list(self.fields.items()):
if k.startswith('mail_text_'):
v.help_text = str(v.help_text) + ', ' + ', '.join({
'{meta_' + p + '}' for p in keys
})
v.validators[0].limit_value += ['{meta_' + p + '}' for p in keys]
if '{attendee_name}' in v.validators[0].limit_value:
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
v.help_text = str(v.help_text) + ', ' + '{attendee_name_%s}' % f
v.validators[0].limit_value += ['{attendee_name_' + f + '}']
if k.endswith('_attendee') and not event.settings.attendee_emails_asked:
# If we don't ask for attendee emails, we can't send them anything and we don't need to clutter
# the user interface with it
del self.fields[k]
def clean(self):
data = self.cleaned_data
if not data.get('smtp_password') and data.get('smtp_username'):
@@ -1046,6 +1166,7 @@ class DisplaySettingsForm(SettingsForm):
choices=[
('Open Sans', 'Open Sans')
],
widget=FontSelect,
help_text=_('Only respected by modern browsers.')
)
frontpage_text = I18nFormField(
+20 -6
View File
@@ -132,7 +132,7 @@ class OrderFilterForm(FilterForm):
matching_positions = OrderPosition.objects.filter(
Q(order=OuterRef('pk')) & Q(
Q(attendee_name_cached__icontains=u) | Q(attendee_email__icontains=u)
| Q(secret__istartswith=u)
| Q(secret__istartswith=u) | Q(voucher__code__icontains=u)
)
).values('id')
@@ -177,11 +177,9 @@ class EventOrderFilterForm(OrderFilterForm):
orders = {'code': 'code', 'email': 'email', 'total': 'total',
'datetime': 'datetime', 'status': 'status'}
item = forms.ModelChoiceField(
item = forms.ChoiceField(
label=_('Products'),
queryset=Item.objects.none(),
required=False,
empty_label=_('All products')
)
subevent = forms.ModelChoiceField(
label=pgettext_lazy('subevent', 'Date'),
@@ -241,12 +239,28 @@ class EventOrderFilterForm(OrderFilterForm):
elif 'subevent':
del self.fields['subevent']
choices = [('', _('All products'))]
for i in self.event.items.prefetch_related('variations').all():
variations = list(i.variations.all())
if variations:
choices.append((str(i.pk), _('{product} – Any variation').format(product=i.name)))
for v in variations:
choices.append(('%d-%d' % (i.pk, v.pk), '%s – %s' % (i.name, v.value)))
else:
choices.append((str(i.pk), i.name))
self.fields['item'].choices = choices
def filter_qs(self, qs):
fdata = self.cleaned_data
qs = super().filter_qs(qs)
if fdata.get('item'):
qs = qs.filter(all_positions__item=fdata.get('item'), all_positions__canceled=False).distinct()
item = fdata.get('item')
if item:
if '-' in item:
var = item.split('-')[1]
qs = qs.filter(all_positions__variation_id=var, all_positions__canceled=False).distinct()
else:
qs = qs.filter(all_positions__item_id=fdata.get('item'), all_positions__canceled=False).distinct()
if fdata.get('subevent'):
qs = qs.filter(all_positions__subevent=fdata.get('subevent'), all_positions__canceled=False).distinct()
+14
View File
@@ -6,6 +6,9 @@ from django.urls import reverse
from django.utils.translation import (
pgettext_lazy, ugettext as __, ugettext_lazy as _,
)
from django_scopes.forms import (
SafeModelChoiceField, SafeModelMultipleChoiceField,
)
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.base.channels import get_all_sales_channels
@@ -94,6 +97,10 @@ class QuestionForm(I18nModelForm):
),
'dependency_value': forms.Select,
}
field_classes = {
'items': SafeModelMultipleChoiceField,
'dependency_question': SafeModelChoiceField,
}
class QuestionOptionForm(I18nModelForm):
@@ -159,6 +166,9 @@ class QuotaForm(I18nModelForm):
'size',
'subevent'
]
field_classes = {
'subevent': SafeModelChoiceField,
}
def save(self, *args, **kwargs):
creating = not self.instance.pk
@@ -206,6 +216,8 @@ class ItemCreateForm(I18nModelForm):
empty_label=_('Do not copy'),
required=False
)
if self.event.tax_rules.exists():
self.fields['tax_rule'].required = True
if not self.event.has_subevents:
choices = [
@@ -364,6 +376,8 @@ class ItemUpdateForm(I18nModelForm):
'over 65. This ticket includes access to all parts of the event, except the VIP '
'area.'
)
if self.event.tax_rules.exists():
self.fields['tax_rule'].required = True
self.fields['description'].widget.attrs['rows'] = '4'
self.fields['sales_channels'] = forms.MultipleChoiceField(
label=_('Sales channels'),
+53 -7
View File
@@ -9,7 +9,10 @@ from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from pretix.base.forms import I18nModelForm, PlaceholderValidator
from pretix.base.models import InvoiceAddress, ItemAddOn, Order, OrderPosition
from pretix.base.forms.widgets import DatePickerWidget
from pretix.base.models import (
InvoiceAddress, ItemAddOn, Order, OrderPosition, Seat,
)
from pretix.base.models.event import SubEvent
from pretix.base.services.pricing import get_price
from pretix.control.forms.widgets import Select2
@@ -116,6 +119,12 @@ class MarkPaidForm(ConfirmPaymentForm):
localize=True,
label=_('Payment amount'),
)
payment_date = forms.DateField(
required=True,
label=_('Payment date'),
widget=DatePickerWidget(),
initial=now
)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -185,10 +194,16 @@ class OrderPositionAddForm(forms.Form):
label=_('Product')
)
addon_to = forms.ModelChoiceField(
OrderPosition.objects.none(),
OrderPosition.all.none(),
required=False,
label=_('Add-on to'),
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
required=False,
label=_('Seat'),
empty_label=_('General admission')
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -234,6 +249,19 @@ class OrderPositionAddForm(forms.Form):
else:
del self.fields['addon_to']
self.fields['seat'].queryset = order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': order.event.slug,
'organizer': order.event.organizer.slug,
}),
'data-placeholder': _('General admission')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
if order.event.has_subevents:
self.fields['subevent'].queryset = order.event.subevents.all()
self.fields['subevent'].widget = Select2(
@@ -262,6 +290,11 @@ class OrderPositionChangeForm(forms.Form):
required=False,
empty_label=_('(Unchanged)')
)
seat = forms.ModelChoiceField(
Seat.objects.none(),
required=False,
empty_label=_('(Unchanged)')
)
price = forms.DecimalField(
required=False,
max_digits=10, decimal_places=2,
@@ -285,10 +318,7 @@ class OrderPositionChangeForm(forms.Form):
instance = kwargs.pop('instance')
initial = kwargs.get('initial', {})
if instance.item.tax_rule and not instance.item.tax_rule.price_includes_tax:
initial['price'] = instance.price - instance.tax_value
else:
initial['price'] = instance.price
initial['price'] = instance.price
kwargs['initial'] = initial
super().__init__(*args, **kwargs)
@@ -308,6 +338,22 @@ class OrderPositionChangeForm(forms.Form):
else:
del self.fields['subevent']
if instance.seat:
self.fields['seat'].queryset = instance.order.event.seats.all()
self.fields['seat'].widget = Select2(
attrs={
'data-model-select2': 'seat',
'data-select2-url': reverse('control:event.seats.select2', kwargs={
'event': instance.order.event.slug,
'organizer': instance.order.event.organizer.slug,
}),
'data-placeholder': _('(Unchanged)')
}
)
self.fields['seat'].widget.choices = self.fields['seat'].choices
else:
del self.fields['seat']
choices = [
('', _('(Unchanged)'))
]
@@ -340,7 +386,7 @@ class OrderContactForm(forms.ModelForm):
class Meta:
model = Order
fields = ['email']
fields = ['email', 'email_known_to_work']
class OrderLocaleForm(forms.ModelForm):
+14 -1
View File
@@ -6,13 +6,16 @@ from django.core.exceptions import ValidationError
from django.core.validators import RegexValidator
from django.utils.safestring import mark_safe
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes.forms import SafeModelMultipleChoiceField
from i18nfield.forms import I18nFormField, I18nTextarea
from pretix.api.models import WebHook
from pretix.api.webhooks import get_all_webhook_events
from pretix.base.forms import I18nModelForm, SettingsForm
from pretix.base.models import Device, Organizer, Team
from pretix.control.forms import ExtFileField, MultipleLanguagesWidget
from pretix.control.forms import (
ExtFileField, FontSelect, MultipleLanguagesWidget,
)
from pretix.multidomain.models import KnownDomain
from pretix.presale.style import get_fonts
@@ -147,6 +150,9 @@ class TeamForm(forms.ModelForm):
'data-inverse-dependency': '#id_all_events'
}),
}
field_classes = {
'limit_events': SafeModelMultipleChoiceField
}
def clean(self):
data = super().clean()
@@ -175,6 +181,9 @@ class DeviceForm(forms.ModelForm):
'data-inverse-dependency': '#id_all_events'
}),
}
field_classes = {
'limit_events': SafeModelMultipleChoiceField
}
class OrganizerSettingsForm(SettingsForm):
@@ -260,6 +269,7 @@ class OrganizerDisplaySettingsForm(SettingsForm):
choices=[
('Open Sans', 'Open Sans')
],
widget=FontSelect,
help_text=_('Only respected by modern browsers.')
)
favicon = ExtFileField(
@@ -304,3 +314,6 @@ class WebHookForm(forms.ModelForm):
'data-inverse-dependency': '#id_all_events'
}),
}
field_classes = {
'limit_events': SafeModelMultipleChoiceField
}
+1 -1
View File
@@ -36,7 +36,7 @@ class SubEventForm(I18nModelForm):
'presale_start',
'presale_end',
'location',
'frontpage_text'
'frontpage_text',
]
field_classes = {
'date_from': SplitDateTimeField,
+3 -1
View File
@@ -48,12 +48,14 @@ class UserEditForm(forms.ModelForm):
'email',
'require_2fa',
'is_active',
'is_staff'
'is_staff',
'last_login'
]
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['email'].required = True
self.fields['last_login'].disabled = True
def clean_email(self):
email = self.cleaned_data['email']
+13 -2
View File
@@ -3,6 +3,7 @@ from django.core.exceptions import ObjectDoesNotExist, ValidationError
from django.db.models.functions import Lower
from django.urls import reverse
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes.forms import SafeModelChoiceField
from pretix.base.forms import I18nModelForm
from pretix.base.models import Item, Voucher
@@ -31,10 +32,11 @@ class VoucherForm(I18nModelForm):
localized_fields = '__all__'
fields = [
'code', 'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag',
'comment', 'max_usages', 'price_mode', 'subevent'
'comment', 'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
]
field_classes = {
'valid_until': SplitDateTimeField,
'subevent': SafeModelChoiceField,
}
widgets = {
'valid_until': SplitDateTimePickerWidget(),
@@ -146,6 +148,14 @@ class VoucherForm(I18nModelForm):
data, self.instance.event,
self.instance.quota, self.instance.item, self.instance.variation
)
if self.instance.quota:
if all(i.hide_without_voucher for i in self.instance.quota.items.all()):
raise ValidationError({
'itemvar': [
_('The quota you selected only contains hidden products. Hidden products can currently only be '
'shown by using vouchers that directly apply to the product, not via a quota.')
]
})
Voucher.clean_subevent(
data, self.instance.event
)
@@ -187,10 +197,11 @@ class VoucherBulkForm(VoucherForm):
localized_fields = '__all__'
fields = [
'valid_until', 'block_quota', 'allow_ignore_quota', 'value', 'tag', 'comment',
'max_usages', 'price_mode', 'subevent'
'max_usages', 'price_mode', 'subevent', 'show_hidden_items'
]
field_classes = {
'valid_until': SplitDateTimeField,
'subevent': SafeModelChoiceField,
}
widgets = {
'valid_until': SplitDateTimePickerWidget(),
+9
View File
@@ -42,6 +42,12 @@ def _display_order_changed(event: Event, logentry: LogEntry):
old_price=money_filter(Decimal(data['old_price']), event.currency),
new_price=money_filter(Decimal(data['new_price']), event.currency),
)
elif logentry.action_type == 'pretix.event.order.changed.seat':
return text + ' ' + _('Position #{posid}: Seat "{old_seat}" changed '
'to "{new_seat}".').format(
posid=data.get('positionid', '?'),
old_seat=data.get('old_seat'), new_seat=data.get('new_seat'),
)
elif logentry.action_type == 'pretix.event.order.changed.subevent':
old_se = str(event.subevents.get(pk=data['old_subevent']))
new_se = str(event.subevents.get(pk=data['new_subevent']))
@@ -251,6 +257,9 @@ def pretixcontrol_logentry_display(sender: Event, logentry: LogEntry, **kwargs):
'pretix.event.item.addons.added': _('An add-on has been added to this product.'),
'pretix.event.item.addons.removed': _('An add-on has been removed from this product.'),
'pretix.event.item.addons.changed': _('An add-on has been changed on this product.'),
'pretix.event.item.bundles.added': _('A bundled item has been added to this product.'),
'pretix.event.item.bundles.removed': _('A bundled item has been removed from this product.'),
'pretix.event.item.bundles.changed': _('A bundled item has been changed on this product.'),
'pretix.event.quota.added': _('The quota has been added.'),
'pretix.event.quota.deleted': _('The quota has been deleted.'),
'pretix.event.quota.changed': _('The quota has been changed.'),
+38 -9
View File
@@ -4,10 +4,11 @@ from django.conf import settings
from django.contrib.auth import REDIRECT_FIELD_NAME, logout
from django.http import Http404
from django.shortcuts import get_object_or_404, redirect, resolve_url
from django.template.response import TemplateResponse
from django.urls import get_script_prefix, resolve, reverse
from django.utils.deprecation import MiddlewareMixin
from django.utils.encoding import force_str
from django.utils.translation import ugettext as _
from django_scopes import scope
from hijack.templatetags.hijack_tags import is_hijacked
from pretix.base.models import Event, Organizer
@@ -17,7 +18,7 @@ from pretix.helpers.security import (
)
class PermissionMiddleware(MiddlewareMixin):
class PermissionMiddleware:
"""
This middleware enforces all requests to the control app to require login.
Additionally, it enforces all requests to "control:event." URLs
@@ -34,6 +35,23 @@ class PermissionMiddleware(MiddlewareMixin):
"user.settings.notifications.off",
)
EXCEPTIONS_2FA = (
"user.settings.2fa",
"user.settings.2fa.add",
"user.settings.2fa.enable",
"user.settings.2fa.disable",
"user.settings.2fa.regenemergency",
"user.settings.2fa.confirm.totp",
"user.settings.2fa.confirm.u2f",
"user.settings.2fa.delete",
"auth.logout",
"user.reauth"
)
def __init__(self, get_response=None):
self.get_response = get_response
super().__init__()
def _login_redirect(self, request):
# Taken from django/contrib/auth/decorators.py
path = request.build_absolute_uri()
@@ -52,19 +70,19 @@ class PermissionMiddleware(MiddlewareMixin):
return redirect_to_login(
path, resolved_login_url, REDIRECT_FIELD_NAME)
def process_request(self, request):
def __call__(self, request):
url = resolve(request.path_info)
url_name = url.url_name
if not request.path.startswith(get_script_prefix() + 'control'):
# This middleware should only touch the /control subpath
return
return self.get_response(request)
if hasattr(request, 'organizer'):
# If the user is on a organizer's subdomain, he should be redirected to pretix
return redirect(urljoin(settings.SITE_URL, request.get_full_path()))
if url_name in self.EXCEPTIONS:
return
return self.get_response(request)
if not request.user.is_authenticated:
return self._login_redirect(request)
@@ -78,11 +96,16 @@ class PermissionMiddleware(MiddlewareMixin):
if url_name not in ('user.reauth', 'auth.logout'):
return redirect(reverse('control:user.reauth') + '?next=' + quote(request.get_full_path()))
if not request.user.require_2fa and settings.PRETIX_OBLIGATORY_2FA \
and url_name not in self.EXCEPTIONS_2FA:
return redirect(reverse('control:user.settings.2fa'))
if 'event' in url.kwargs and 'organizer' in url.kwargs:
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer').first()
with scope(organizer=None):
request.event = Event.objects.filter(
slug=url.kwargs['event'],
organizer__slug=url.kwargs['organizer'],
).select_related('organizer').first()
if not request.event or not request.user.has_event_permission(request.event.organizer, request.event,
request=request):
raise Http404(_("The selected event was not found or you "
@@ -104,6 +127,12 @@ class PermissionMiddleware(MiddlewareMixin):
else:
request.orgapermset = request.user.get_organizer_permission_set(request.organizer)
with scope(organizer=getattr(request, 'organizer', None)):
r = self.get_response(request)
if isinstance(r, TemplateResponse):
r = r.render()
return r
class AuditLogMiddleware:
+5 -3
View File
@@ -281,7 +281,7 @@ def get_event_navigation(request: HttpRequest):
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_event.send(request.event, request=request)), []),
key=lambda r: r['label']
key=lambda r: (1 if r.get('parent') else 0, r['label'])
))
return nav
@@ -391,7 +391,7 @@ def get_global_navigation(request):
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_global.send(request, request=request)), []),
key=lambda r: r['label']
key=lambda r: (1 if r.get('parent') else 0, r['label'])
))
return nav
@@ -464,7 +464,7 @@ def get_organizer_navigation(request):
merge_in(nav, sorted(
sum((list(a[1]) for a in nav_organizer.send(request.organizer, request=request, organizer=request.organizer)),
[]),
key=lambda r: r['label']
key=lambda r: (1 if r.get('parent') else 0, r['label'])
))
return nav
@@ -474,6 +474,8 @@ def merge_in(nav, newnav):
if 'parent' in item:
parents = [n for n in nav if n['url'] == item['parent']]
if parents:
if 'children' not in parents[0]:
parents[0]['children'] = []
parents[0]['children'].append(item)
else:
nav.append(item)
+52
View File
@@ -36,6 +36,12 @@ on the type of navigation. You should also return an ``active`` key with a boole
set to ``True``, when this item should be marked as active. The ``request`` object
will have an attribute ``event``.
You can optionally create sub-items to create hierarchical navigation. There are two
ways to achieve this: Either you specify a key ``children`` on your top navigation item
that contains a list of navigation items (as dictionaries), or you specify a ``parent``
key with the ``url`` value of the designated parent item.
The latter method also allows you to register navigation items as a sub-item of existing ones.
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
in pretix.
@@ -73,6 +79,12 @@ a fontawesome icon name with the key ``icon``, it will be respected depending
on the type of navigation. You should also return an ``active`` key with a boolean
set to ``True``, when this item should be marked as active.
You can optionally create sub-items to create hierarchical navigation. There are two
ways to achieve this: Either you specify a key ``children`` on your top navigation item
that contains a list of navigation items (as dictionaries), or you specify a ``parent``
key with the ``url`` value of the designated parent item.
The latter method also allows you to register navigation items as a sub-item of existing ones.
If you use this, you should read the documentation on :ref:`how to deal with URLs <urlconf>`
in pretix.
@@ -173,6 +185,12 @@ should contain at least the keys ``label`` and ``url``. You should also return
an ``active`` key with a boolean set to ``True``, when this item should be marked
as active.
You can optionally create sub-items to create hierarchical navigation. There are two
ways to achieve this: Either you specify a key ``children`` on your top navigation item
that contains a list of navigation items (as dictionaries), or you specify a ``parent``
key with the ``url`` value of the designated parent item.
The latter method also allows you to register navigation items as a sub-item of existing ones.
If your linked view should stay in the tab-like context of this page, we recommend
that you use ``pretix.control.views.organizer.OrganizerDetailViewMixin`` for your view
and your template inherits from ``pretixcontrol/organizers/base.html``.
@@ -219,6 +237,24 @@ As with all plugin signals, the ``sender`` keyword argument will contain the eve
A second keyword argument ``request`` will contain the request object.
"""
nav_item = EventPluginSignal(
providing_args=['request', 'item']
)
"""
This signal is sent out to include tab links on the settings page of an item.
Receivers are expected to return a list of dictionaries. The dictionaries
should contain at least the keys ``label`` and ``url``. You should also return
an ``active`` key with a boolean set to ``True``, when this item should be marked
as active.
If your linked view should stay in the tab-like context of this page, we recommend
that you use ``pretix.control.views.item.ItemDetailMixin`` for your view
and your template inherits from ``pretixcontrol/item/base.html``.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
A second keyword argument ``request`` will contain the request object.
"""
event_settings_widget = EventPluginSignal(
providing_args=['request']
)
@@ -243,6 +279,22 @@ styles. It is advisable to set a prefix for your form to avoid clashes with othe
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
subevent_forms = EventPluginSignal(
providing_args=['request', 'subevent']
)
"""
This signal allows you to return additional forms that should be rendered on the subevent creation
or modification page. You are passed ``request`` and ``subevent`` arguments and are expected to return
an instance of a form class that you bind yourself when appropriate. Your form will be executed
as part of the standard validation and rendering cycle and rendered using default bootstrap
styles. It is advisable to set a prefix for your form to avoid clashes with other plugins.
``subevent`` can be ``None`` during creation. Before ``save()`` is called, a ``subevent`` property of
your form instance will automatically being set to the subevent that has just been created.
As with all plugin signals, the ``sender`` keyword argument will contain the event.
"""
oauth_application_registered = Signal(
providing_args=["user", "application"]
)
@@ -145,7 +145,11 @@
<a href="{{ nav.url }}" title="{{ nav.title }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="dropdown-toggle" data-toggle="dropdown"{% endif %}>
{% if nav.icon %}
<span class="fa fa-{{ nav.icon }}"></span>
{% if "<svg" in nav.icon %}
{{ nav.icon|safe }}
{% else %}
<span class="fa fa-{{ nav.icon }}"></span>
{% endif %}
<span class="visible-xs-inline">{{ nav.label }}</span>
{% else %}
{{ nav.label }}
@@ -242,7 +246,10 @@
<span class="caret"></span></a>
{% endif %}
<ul class="dropdown-menu event-dropdown" role="menu" data-event-typeahead
data-source="{% url "control:nav.typeahead" %}">
data-source="{% url "control:nav.typeahead" %}"
{% if request.event %}
data-organizer="{{ request.organizer.id }}"
{% endif %}>
<li class="query-holder">
<div class="form-box">
<input type="text" class="form-control" id="event-dropdown-field"
@@ -267,7 +274,11 @@
<a href="{{ nav.url }}" {% if nav.active %}class="active"{% endif %}
{% if nav.children %}class="has-children"{% endif %}>
{% if nav.icon %}
<i class="fa fa-{{ nav.icon }} fa-fw"></i>
{% if "<svg" in nav.icon %}
{{ nav.icon|safe }}
{% else %}
<span class="fa fa-fw fa-{{ nav.icon }}"></span>
{% endif %}
{% endif %}
{{ nav.label }}
</a>
@@ -2,6 +2,10 @@
{% load i18n %}
{% load bootstrap3 %}
{% load hierarkey_form %}
{% block custom_header %}
{{ block.super }}
<link type="text/css" rel="stylesheet" href="{% url "control:pdf.css" %}">
{% endblock %}
{% block inside %}
<h1>{% trans "Display settings" %}</h1>
<form action="" method="post" class="form-horizontal" enctype="multipart/form-data">

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