Compare commits

...

406 Commits

Author SHA1 Message Date
Raphael Michel
b704d21e88 Bump to 3.2.0 2019-10-10 18:16:25 +02:00
Raphael Michel
4bfe0e3784 Order change manager: Allow to add multiple products 2019-10-10 12:59:16 +02:00
Raphael Michel
d4d046ca60 Order change manager: Allow to disable invoice issuing 2019-10-10 12:19:06 +02:00
Raphael Michel
fb3fc05522 Remove references to our legacy apps 2019-10-10 09:27:14 +02:00
Raphael Michel
fdcd750487 Disable autocomplete in context switcher 2019-10-09 17:16:47 +02:00
Raphael Michel
92754136a6 Refs #1432 -- Proper grouping of autocomplete properties 2019-10-09 12:40:05 +02:00
Raphael Michel
3b4d39ec27 Fix #1432 -- Correct autocomplete attributes of name part fields 2019-10-09 12:40:05 +02:00
Sohalt
88bb3b483c Only disable up arrow on first page and down arrow on last page (#1437) 2019-10-09 09:15:35 +02:00
Raphael Michel
247370839b Fix placeholder form validation 2019-10-09 08:58:05 +02:00
Raphael Michel
b0510f47b3 Move new option down a little 2019-10-08 17:43:28 +02:00
Raphael Michel
4fa086bbc5 Update from Weblate (#1435)
Update from Weblate
2019-10-08 16:09:46 +02:00
Raphael Michel
cb03e7c843 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3218 of 3218 strings)

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

powered by weblate
2019-10-08 14:09:12 +00:00
Raphael Michel
d012d0804a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3218 of 3218 strings)

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

powered by weblate
2019-10-08 14:09:11 +00:00
Raphael Michel
3731d5f431 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-10-08 15:57:05 +02:00
Raphael Michel
9f04d53564 Add safety mechanism for order code generation 2019-10-08 15:53:37 +02:00
Martin Gross
748a389acb Auto-check-in for specific sales channels (#1409)
* Autocheckin data model/cosmetics

* Expose automatically checked-in OrderPositions

* Expose automatically checked-in OrderPositions in CSV/PDF Exports

* Fix some tests, try to fix MultiStringField/CheckboxSelectMultiple

* Actually fix MultiStringField/CheckboxSelectMultiple.
(Not pretty, but it works)

* Fix more tests

* Squash migration

* Also fix CSV/nameparts-test

* Changes for Autocheckin code-review

* Perform Auto-Checkins through new core plugin

* Update config-doc to reflect also checkinlists

* Explicitly output AutoCheckin Yes/No for CSV-Export (+ fix test)

* Move autocheckin from plugin to service

* API-doc

* Fix API-doc spelling

* Checkinlist-API and autocheckin order tests

* Performance improvement when reading checkinlists for autocheckin

Co-Authored-By: Raphael Michel <michel@rami.io>

* Autocheckin test for order created through API

* Resolve migration conflict
2019-10-08 15:50:22 +02:00
Sohalt
05a1df244b Fix #1388 -- Prevent some words from occurring in order codes (#1422)
* prevent some words from occurring in order codes

* Use regex to match against blacklist

* Prevent some words from occurring in voucher codes

* Rename blacklist to banlist
2019-10-08 14:28:51 +02:00
Raphael Michel
9f7d5156cc Refs #1430 -- Fix untranslated string 2019-10-08 12:29:10 +02:00
Martin Gross
143fe6c1a6 Fix #1430 - Fix fieldname-filter for BaseInvoiceNameForm 2019-10-07 17:48:18 +02:00
Raphael Michel
1e3ccc4449 Merge pull request #1431 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-10-07 12:16:32 +02:00
Raphael Michel
d09000b716 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3210 of 3210 strings)

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

powered by weblate
2019-10-07 10:14:52 +00:00
Raphael Michel
fc4572767e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3210 of 3210 strings)

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

powered by weblate
2019-10-07 10:14:51 +00:00
Raphael Michel
eeedd9a9c2 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-10-07 11:49:10 +02:00
Raphael Michel
1d0c148170 Fix #467 -- Pluggable email placeholders (#1429)
* Fix #467 -- Pluggable email placeholders

* Previews

* Polishing

* Fix tests

* Add missing doc file
2019-10-07 11:48:25 +02:00
Raphael Michel
cb37e7435d Use a different-colored favicon in development mode 2019-10-07 09:03:46 +02:00
Raphael Michel
749ddbf21c Allow topbar navigation subitems not to have an URL 2019-10-06 17:02:53 +02:00
Raphael Michel
9f6634025f Update from Weblate (#1425)
Update from Weblate
2019-10-06 11:56:32 +02:00
Chris Spy
ad039ae08e Translated on translate.pretix.eu (Greek)
Currently translated at 96.1% (99 of 103 strings)

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

powered by weblate
2019-10-06 09:53:51 +00:00
Chris Spy
eeaaca574d Translated on translate.pretix.eu (Greek)
Currently translated at 95.5% (3076 of 3220 strings)

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

powered by weblate
2019-10-06 09:53:51 +00:00
Fabian Rodriguez
bef15ec442 Translated on translate.pretix.eu (French)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-10-06 09:53:51 +00:00
Raphael Michel
447a8b0a8c Add missing mark_safe call 2019-10-06 11:53:37 +02:00
Sohalt
05b4d954d9 Make voucher code in notification clickable (#1423)
* Make voucher code in notification clickable

* Move html out of translated string
2019-10-06 11:52:34 +02:00
Felix Rindt
515d8c4899 remove .csv from default filename in List Exporter (#1428) 2019-10-06 11:51:24 +02:00
Raphael Michel
82497cfb89 Lazy-load widgets on global dashboard 2019-10-06 11:46:42 +02:00
Raphael Michel
4ade9d39cd Add "back" parameter to logout view 2019-10-06 11:35:29 +02:00
Raphael Michel
4360d5652b ALlow to pre-select organizer for event creation 2019-10-06 11:35:29 +02:00
Raphael Michel
9fca3188b2 Device and team creation: List events ordered and with date 2019-10-04 17:28:48 +02:00
Raphael Michel
0ed48fac7f Add pagination to event list on organizer detail page 2019-10-04 17:28:48 +02:00
Raphael Michel
27a32173e6 Move more code into change_payment_provider 2019-10-04 17:28:48 +02:00
Martin Gross
81b6188777 New ApplePay domain association file for Stripe 2019-10-02 21:31:40 +02:00
Raphael Michel
9e85d3c94c PDF editor: Catch ValueError during float conversion 2019-09-30 14:37:09 +02:00
Raphael Michel
4e58ba7594 money_filter: Idempotency on empty strings
PRETIXEU-1EH
2019-09-30 14:37:09 +02:00
Raphael Michel
248493dbf2 Stripe plugin: Fix AttributeError 2019-09-29 18:32:19 +02:00
Raphael Michel
f1bd240096 Update from Weblate (#1417)
Update from Weblate
2019-09-29 14:05:15 +02:00
Serge Bazanski
0b673dc68c Translated on translate.pretix.eu (Polish)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-09-27 07:11:22 +00:00
Serge Bazanski
b2a7fe13da Translated on translate.pretix.eu (Polish)
Currently translated at 14.7% (474 of 3220 strings)

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

powered by weblate
2019-09-27 07:11:22 +00:00
Martin Gross
d28f735fca Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-27 07:11:22 +00:00
Jonathan Oberländer
7a3b450bc6 Added translation on translate.pretix.eu (Romanian) 2019-09-27 07:11:22 +00:00
Raphael Michel
f1ec129c0a Fix ZeroDivisionError 2019-09-27 09:11:03 +02:00
Raphael Michel
ce6e46dfd2 Fix performance of check-in list API list 2019-09-26 15:18:53 +02:00
Martin Gross
f296f262e6 Properly indent handling for non-addons 2019-09-23 11:12:50 +02:00
Martin Gross
7f8d290ae1 Add-Ons inhert question-answers from parent item if necessary 2019-09-23 10:47:05 +02:00
Raphael Michel
ca0c0f4ae3 Revert to multipart/related 2019-09-20 11:22:11 +02:00
Sohalt
ac1e69f2d8 Install pretix into system-site-packages in Docker image (#1410) 2019-09-20 09:43:00 +02:00
Raphael Michel
6338cc69ae docs: fix BaseDataShredder class documentation (#1412)
The BaseDataShredder documentation contained two references to BaseInvoiceRenderer, probably due to a minor copy/paste mistake
2019-09-20 09:41:32 +02:00
Felix Rindt
39eaf3ad6a Code style improvements (#1411)
* docstring corrections

* move omit_hyphen formfield
2019-09-20 09:34:24 +02:00
Raphael Michel
76e75bef65 Fix broken organizer settings test 2019-09-19 18:33:30 +02:00
Raphael Michel
a39822aedc Use transaction aware task for regenerate_css 2019-09-19 18:17:43 +02:00
Raphael Michel
73d5a2cec0 Revert "Make all EventTasks transaction-aware"
This reverts commit 3f7807d242.
2019-09-19 18:15:47 +02:00
Raphael Michel
9f668e5fd6 Fix crash in OrderEmailHistory for unknown orders 2019-09-19 18:04:31 +02:00
Raphael Michel
1b92a891d7 Fix issues with context providers in error pages 2019-09-19 18:03:35 +02:00
Raphael Michel
827925e3c9 Fix bug in 3f7807d24 2019-09-19 18:03:28 +02:00
Johannes Lauinger
c0be574974 docs: fix BaseDataShredder class documentation
The BaseDataShredder documentation contained two references to BaseInvoiceRenderer, probably due to a minor copy/paste mistake
2019-09-19 17:57:07 +02:00
Raphael Michel
738413e8fd Allow to copy categories and quotas 2019-09-19 16:59:25 +02:00
Raphael Michel
2c6125adeb Allow to copy vouchers 2019-09-19 16:33:59 +02:00
Raphael Michel
3f7807d242 Make all EventTasks transaction-aware 2019-09-19 16:23:40 +02:00
Raphael Michel
59b7f0a03d Fix a new bug in Stripe webhooks PRETIXEU-64 2019-09-19 10:36:33 +02:00
Raphael Michel
b7dea16db3 Promote danish to an inofficial language 2019-09-19 10:13:34 +02:00
Raphael Michel
3a81706aeb Fix KeyError in bank import 2019-09-17 16:48:41 +02:00
Raphael Michel
ad3369b059 Fix issue on old-style webhook URLs (#1385)
* Fix issue on old-style webhook URLs

* Fix test issue
2019-09-17 13:59:57 +02:00
Martin Gross
ab0709558d Fix #1389: PaymentIntents are 'processing' and not 'pending' 2019-09-17 13:03:22 +02:00
Raphael Michel
e2e64ac01d Event selector: Prevent race conditions 2019-09-17 10:08:58 +02:00
Martin Gross
cf14dcf889 Add Subevent-Filter for Voucher-Tags (#1407)
* Add Subevent-Filter for Voucher-Tags

* Filter Subevent Voucher-Tags with proper Filter

* Apply filter before annotating totals and usage
2019-09-16 14:08:23 +02:00
Raphael Michel
a884a25d2b Update from Weblate (#1403)
Update from Weblate
2019-09-16 13:42:41 +02:00
Gianmarco Palumbo
8493778028 Translated on translate.pretix.eu (Italian)
Currently translated at 44.7% (46 of 103 strings)

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

powered by weblate
2019-09-13 18:00:10 +00:00
Martin Gross
36a276c360 Translated on translate.pretix.eu (French)
Currently translated at 72.5% (2335 of 3220 strings)

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

powered by weblate
2019-09-13 18:00:09 +00:00
Maarten van den Berg
795c423d73 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-12 22:00:12 +00:00
Maarten van den Berg
3ce9ec79f0 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-12 22:00:10 +00:00
Translators EN IT Team
debc5e255b Translated on translate.pretix.eu (Italian)
Currently translated at 4.7% (151 of 3220 strings)

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

powered by weblate
2019-09-12 00:00:09 +00:00
Raphael Michel
145aa0d7fd Merge pull request #1397 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-09-11 08:58:42 +02:00
Raphael Michel
3997bdd098 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-11 06:58:06 +00:00
Raphael Michel
a51a905512 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-11 06:58:06 +00:00
Maarten van den Berg
d3ac9e8880 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-11 06:55:48 +00:00
Maarten van den Berg
07402c9ea0 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-11 06:55:47 +00:00
Raphael Michel
5f81bf55ca Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-09-11 08:55:36 +02:00
Raphael Michel
0120a5a930 Clarify cancellation description 2019-09-11 08:55:03 +02:00
Raphael Michel
ba555f956e Fix #1395 -- Incorrect data in tax list exporter if an order contains multiple tax rates 2019-09-10 14:41:49 +02:00
Raphael Michel
5e3876ddde Tax exporters: Use lazy gettext calls 2019-09-10 14:40:05 +02:00
Raphael Michel
f5719687aa Use lazy translation for exporter name 2019-09-10 13:13:15 +02:00
Raphael Michel
18449efcc7 Fix #1394 -- Forcefully update defusedcsv 2019-09-10 12:10:41 +02:00
Raphael Michel
3bb20d943a Fix incorrect condition in template 2019-09-10 11:58:02 +02:00
Raphael Michel
586e544fce Add "resend link" option to attendees 2019-09-10 11:44:59 +02:00
Raphael Michel
3a4fc69db1 Add link to ticket page to order page in backend 2019-09-10 11:34:52 +02:00
Raphael Michel
8b5d49d82f Dekodi exporter: Fix AttributeError 2019-09-10 11:34:19 +02:00
Raphael Michel
7665faa39f Bump version to 3.2.0.dev0 2019-09-10 10:54:25 +02:00
Raphael Michel
027d28e646 Bump verstion to 3.1.0 2019-09-10 10:50:18 +02:00
Raphael Michel
babcf66a2e Update from Weblate (#1393)
Update from Weblate
2019-09-10 10:18:46 +02:00
Raphael Michel
20f9e88d61 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-10 08:16:51 +00:00
Raphael Michel
9594607a8c Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3220 of 3220 strings)

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

powered by weblate
2019-09-10 08:16:50 +00:00
Raphael Michel
ad3bcaf43a Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-09-10 10:00:56 +02:00
Raphael Michel
2c4ee3b3c7 Replace U2F with WebAuthn (#1392)
* Replace U2F with WebAuthn

* Imports

* Fix backwards compatibility

* Add explanatory comment

* Fix tests
2019-09-10 09:58:31 +02:00
Raphael Michel
21451db412 Fix Greek VAT IDs 2019-09-10 09:46:00 +02:00
Raphael Michel
12b1f7d90e Fix #1374 -- Correct encoding of images 2019-09-10 09:39:16 +02:00
Raphael Michel
c3901c567e Correctly respect both attention flag in check-in list exports 2019-09-09 11:07:22 +02:00
Raphael Michel
0a3eddcd5c Merge pull request #1384 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-09-09 09:48:36 +02:00
Raphael Michel
dfa99cd325 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3219 of 3219 strings)

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

powered by weblate
2019-09-09 07:48:08 +00:00
Raphael Michel
62a040255e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3219 of 3219 strings)

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

powered by weblate
2019-09-09 07:48:07 +00:00
Maarten van den Berg
0e2b02c778 Translated on translate.pretix.eu (Dutch)
Currently translated at 99.9% (3216 of 3219 strings)

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

powered by weblate
2019-09-09 05:00:09 +00:00
Ture Gjørup
0f0ed90be9 Translated on translate.pretix.eu (Danish)
Currently translated at 61.2% (63 of 103 strings)

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

powered by weblate
2019-09-08 19:26:46 +00:00
Raphael Michel
b1e19d776c Fix exporter tests 2019-09-08 21:26:31 +02:00
Raphael Michel
0bcc784aaf Include attention flag and comment in order and check-in list exports 2019-09-08 20:52:15 +02:00
Raphael Michel
262fb82237 Add-ons should not have a seat 2019-09-06 11:54:18 +02:00
Raphael Michel
adbe959314 Merge pull request #1383 from pajowu/pajowu/no_test_data
Remove obsolete reference to test data
2019-09-05 23:04:15 +02:00
pajowu
02d0a68d57 Remove obsolete reference to test data 2019-09-05 19:29:04 +02:00
Raphael Michel
d6985123b4 Regenerate event CSS on plugins change 2019-09-04 15:39:23 +02:00
Raphael Michel
f7a356c340 Fixed bug in card extensions 2019-09-03 12:51:38 +02:00
Raphael Michel
c6265b4517 Fix allow_ignore_quota with bundled items 2019-09-03 12:25:22 +02:00
Raphael Michel
1ee352e114 Fix error in locale file 2019-09-03 11:59:05 +02:00
Raphael Michel
6c830a7d36 Do not enforce voucher constraints for bundled items 2019-09-03 11:51:29 +02:00
Raphael Michel
129c360fff Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-09-03 11:36:55 +02:00
Raphael Michel
b5c7ad92b6 Merge pull request #1379 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-09-03 11:36:19 +02:00
Raphael Michel
33efd8c157 order_overview: Allow to restrict to admission products 2019-09-03 11:34:00 +02:00
Bostjan Marusic
f318c8e017 Translated on translate.pretix.eu (Slovenian)
Currently translated at 21.9% (702 of 3208 strings)

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

powered by weblate
2019-09-03 09:22:31 +00:00
Bostjan Marusic
319334706d Translated on translate.pretix.eu (Slovenian)
Currently translated at 18.8% (602 of 3208 strings)

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

powered by weblate
2019-09-03 09:22:31 +00:00
Raphael Michel
1a25138bef PDF editor: Allow to easily change the page size 2019-09-03 11:22:21 +02:00
Raphael Michel
daa5383b89 Fix a layout animation issue in the sidebar 2019-09-02 18:07:10 +02:00
Raphael Michel
31333280d2 Fix download link for addons on ticket pages 2019-08-31 11:59:17 +02:00
Raphael Michel
b78f8d70e8 Add serializable to spelling whitelist 2019-08-30 17:46:15 +02:00
Raphael Michel
a847538a2e Merge pull request #1367 from pretix-translations/weblate-pretix-pretix
Update from Weblate
2019-08-30 17:40:38 +02:00
Raphael Michel
e3ef9eba9e Widget: Fix invalid coloring of days in mobile calendar 2019-08-30 17:37:37 +02:00
Bostjan Marusic
9e4a4402fb Translated on translate.pretix.eu (Slovenian)
Currently translated at 15.6% (502 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Bostjan Marusic
96ed9f5cf5 Translated on translate.pretix.eu (Slovenian)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
oocf
7216cebce5 Translated on translate.pretix.eu (Spanish)
Currently translated at 94.3% (3025 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Bostjan Marusic
a3892fd4de Translated on translate.pretix.eu (Slovenian)
Currently translated at 10.7% (343 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
saad91
7718462528 Translated on translate.pretix.eu (Arabic)
Currently translated at 0.2% (8 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Maarten van den Berg
60b20829f3 Translated on translate.pretix.eu (Dutch (informal))
Currently translated at 100.0% (3208 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Maarten van den Berg
e11d03f418 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3208 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Tobias Sundgren
69e4db58fd Translated on translate.pretix.eu (Swedish)
Currently translated at 100.0% (103 of 103 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Tobias Sundgren
9f27a84f52 Translated on translate.pretix.eu (Swedish)
Currently translated at 1.0% (33 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Maarten van den Berg
e5c204dc95 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (3208 of 3208 strings)

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

powered by weblate
2019-08-30 15:25:28 +00:00
Raphael Michel
7fc7dd0163 Allow to print question answers on invoices 2019-08-30 17:24:57 +02:00
Raphael Michel
aa99dbc830 Add payment provider specific details to the API 2019-08-30 17:04:22 +02:00
Raphael Michel
e3a4ec93fc Fix user log that always shows empty 2019-08-30 13:01:35 +02:00
Raphael Michel
67da6a18a8 Add missing signal to documentation 2019-08-30 12:58:20 +02:00
Raphael Michel
adc4128f9f Fix exceptions introduced in last commit 2019-08-30 11:56:04 +02:00
Raphael Michel
eed217262f Add signal global_email_filter 2019-08-30 11:02:59 +02:00
Raphael Michel
4bae824a03 Add user argument to email_filter 2019-08-30 11:02:59 +02:00
Martin Gross
be1a1f7995 Fix widget-URL for subevents 2019-08-30 10:41:08 +02:00
Martin Gross
f4b81aa032 Fix import sorting 2019-08-28 17:17:42 +02:00
Martin Gross
2559439c4e Fix eMail-previews for per-attendant emails 2019-08-28 16:48:59 +02:00
Martin Gross
fce9117dfd Fix explanation on per-attendant emails 2019-08-28 16:47:54 +02:00
Martin Gross
4c8dc8f31c Show attendee-name of item - even if item is an add-on 2019-08-28 09:04:41 +02:00
Martin Gross
b4f69fb13f Move question hint-icons into their own columns 2019-08-27 15:43:02 +02:00
Martin Gross
2aed894bd4 Hide hidden questions from users in presale 2019-08-27 15:31:22 +02:00
Martin Gross
2ff5416afb Show hints for hidden/check-in questions in question overview 2019-08-27 15:19:28 +02:00
Raphael Michel
59f7098a70 Clean up duplicate seats when generating seats 2019-08-26 16:57:48 +02:00
Raphael Michel
83dd865b78 Fix crash when de-selecting all languages 2019-08-26 16:44:20 +02:00
Raphael Michel
ebf411b7a0 Fail gracefully if seats exist multiple times 2019-08-26 16:33:35 +02:00
Raphael Michel
733a4ce8f4 Fix seatingframe for subevents 2019-08-26 16:27:43 +02:00
Raphael Michel
ad94263374 Fix i18n of OrderError 2019-08-17 14:07:56 +02:00
Raphael Michel
102772ec55 Disable Sentry in shell sessions 2019-08-17 14:05:06 +02:00
Raphael Michel
9a826b694f Fix a type error PRETIXEU-1BF 2019-08-17 12:39:38 +02:00
Raphael Michel
bcf8e9cd04 Add short option to get_date_*_display 2019-08-16 16:44:01 +02:00
Raphael Michel
200ce93bb4 Prevent autoresponders 2019-08-14 15:09:19 +02:00
Raphael Michel
0582a4d9e5 Lazy widgets: Fix missing ID 2019-08-14 09:57:55 +02:00
Raphael Michel
1cbab04108 Docs: Add API guide on custom checkout 2019-08-14 09:22:58 +02:00
Raphael Michel
98c18b162f Order creation API: Add support for reverse charge 2019-08-14 09:22:58 +02:00
Raphael Michel
d972cd4c49 Add new bundled plugin "returnurl" 2019-08-14 09:22:58 +02:00
Raphael Michel
985f354293 Order API: Add order URL 2019-08-14 09:22:58 +02:00
Raphael Michel
9c23216bd1 Order creation API: Do not allow empty orders 2019-08-14 09:22:58 +02:00
Raphael Michel
f8bf44c262 Order payment flow: Allow to be used as an iframe session start 2019-08-14 09:22:58 +02:00
Raphael Michel
6badfdf576 Order API: Expose explicit URL of payment steps 2019-08-14 09:22:58 +02:00
Raphael Michel
c2eba21359 Order creation API: Allow to create orders without payment provider 2019-08-14 09:22:58 +02:00
Raphael Michel
5cda04a994 Order creation API: Allow to pass vouchers 2019-08-14 09:22:58 +02:00
Raphael Michel
bc9d8f5bd8 Order creation API: Do not consume expired carts 2019-08-14 09:22:58 +02:00
Raphael Michel
9d6ff20191 Order creation API: Allow to auto-calculate prices 2019-08-14 09:22:58 +02:00
Raphael Michel
82684e6df3 Order creation API: Allow to send emails 2019-08-14 09:22:58 +02:00
Martin Gross
d681ae4dce Only output GA if event is using seating 2019-08-13 13:26:44 +02:00
Martin Gross
213e724e18 Include Seating in default PDF ticket template 2019-08-13 13:26:21 +02:00
Raphael Michel
6e0b80706c Badge exporter: Exclude add-on positions by default 2019-08-13 10:11:24 +02:00
Raphael Michel
5363f4206e Lazy-load dashboard widgets 2019-08-12 12:19:02 +02:00
Raphael Michel
9bdb715874 Re-order list of devices 2019-08-12 11:48:50 +02:00
Raphael Michel
90a9709838 Redirect to profile after creating a new organizer 2019-08-12 11:45:54 +02:00
Raphael Michel
669b438c91 Allow to disables fee in stats module 2019-08-09 15:02:59 +02:00
Raphael Michel
a1353b3773 Add a note on Google Analytics in the widget documentation 2019-08-09 15:02:59 +02:00
Martin Gross
f5c611982a Do not localize date, time, datetime in csv/excel exports 2019-08-09 13:40:32 +02:00
Raphael Michel
e9ab56486a Fix broken test case 2019-08-09 12:53:56 +02:00
Raphael Michel
74bc495eb7 PDF exporter: Deal with duplicate question answers 2019-08-09 12:36:01 +02:00
Raphael Michel
b0b0f7474d Allow state selection without JavaScript 2019-08-09 12:13:09 +02:00
Raphael Michel
663ff60d0a Update from Weblate (#1363)
Update from Weblate
2019-08-09 11:51:59 +02:00
Raphael Michel
7bafb0bc76 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3208 of 3208 strings)

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

powered by weblate
2019-08-09 08:26:33 +00:00
Raphael Michel
1ab225f40a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (3208 of 3208 strings)

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

powered by weblate
2019-08-09 08:26:33 +00:00
Raphael Michel
3b09456755 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2019-08-09 09:58:52 +02:00
Raphael Michel
d919605d79 Invoice addresses: Ask for a state in some countries (#1362)
* Invoice addresses: Ask for a state in some countries

* API, tests, noscript

* Fix shredder tests

* Add test for addresses with long state names
2019-08-09 09:55:46 +02:00
Raphael Michel
547f71aac6 Widget builder: explicit encoding for file reading 2019-08-08 19:43:44 +02:00
Raphael Michel
191729c07a Remove "3DS mode" setting as it no longer has an effect 2019-08-08 19:25:31 +02:00
Raphael Michel
8f0a5d859d Bump version to 3.1.0.dev0 2019-08-08 10:48:59 +02:00
Raphael Michel
9286ca14f9 Bump version to 3.0.0 2019-08-08 10:48:27 +02:00
Raphael Michel
c5f9a78bdb Update from Weblate (#1361)
Update from Weblate
2019-08-08 10:48:13 +02:00
Raphael Michel
08eb5bfb8f Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (3211 of 3211 strings)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Convert more pages

* Convert question page

* Item form

* Add item_formsets signal

* Revert "Add new signal nav_item"

This reverts commit 1ce613ff89.

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

* Fix required property

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

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

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

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

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

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

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

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

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

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

* Some UI

* API and logging

* Permission check

* Add tests

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

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

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

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

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

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

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

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

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

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

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

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

* Backwards-compatible implementation

* Add test

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

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

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

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

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

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

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

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

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

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

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

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

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

* Move SCA to its own view

* Handle CardErrors for PaymentIntents

* Abilty to handle charge webhooks with PaymentIntents

* Better handling of Stripe References

* Fix Stripe Tests

* Move SCA page into orderlayout; perform iFrame SCA

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

* Optionally pass prefetched PaymentIntent to handle-func

* Fix style

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

* More accurate loading message

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

* Robust error handling

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

* Remove logging statement

* Fix JavaScript interference with other frame events

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

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

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

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

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

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

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

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

* Add seat guids

* Add product_list_top

* CartAdd: Ignore item when a seat is passed

* Cart display

* product_list_top → render_seating_plan

* Render seating plan in voucher redemption

* Fix failing tests

* Add tests for extending cart positions with seats

* Add subevent_forms to docs

* Update schema, migrations

* Dealing with expired orders

* steps to order change

* Change order positions

* Allow to add seats

* tests for ocm

* Fix things after rebase

* Seating plans API

* Add more tests for cart behaviour

* Widget support

* Adjust widget tests

* Re-enable CSP

* Update schema

* Api: position.seat

* Add guid to word list

* API: (sub)event.seating_plan

* Vali fixes

* Fix api

* Fix reference in test

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

* Changes after code-review

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

* Fix tests.api

* Update tasks and cronjobs

* Fix remaining tests

* Remove unused import

* Fix tests after rebase

* Disable scopes for get_Events_with_any_permission

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

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

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

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

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

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

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

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

View File

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

View File

@@ -57,6 +57,8 @@ COPY deployment/docker/nginx.conf /etc/nginx/nginx.conf
COPY deployment/docker/production_settings.py /pretix/src/production_settings.py COPY deployment/docker/production_settings.py /pretix/src/production_settings.py
COPY src /pretix/src COPY src /pretix/src
RUN cd /pretix/src && pip3 install .
RUN chmod +x /usr/local/bin/pretix && \ RUN chmod +x /usr/local/bin/pretix && \
rm /etc/nginx/sites-enabled/default && \ rm /etc/nginx/sites-enabled/default && \
cd /pretix/src && \ cd /pretix/src && \

View File

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

View File

@@ -0,0 +1,132 @@
Creating an external checkout process
=====================================
Occasionally, we get asked whether it is possible to just use pretix' powerful backend as a ticketing engine but use
a fully-customized checkout process that only communicates via the API. This is possible, but with a few limitations.
If you go down this route, you will miss out on many of pretix features and safeguards, as well as the added flexibility
by most of pretix' plugins. We strongly recommend to talk this through with us before you decide this is the way to go.
However, this is really useful if you need to tightly integrate pretix into existing web applications that e.g. control
the pricing of your products in a way that cannot be mapped to pretix' product structures.
Creating orders
---------------
After letting your user select the products to buy in your application, you should create a new order object inside
pretix. Below, you can see an example of such an order, but most fields are optional and there are some more features
supported. Read :ref:`rest-orders-create` to learn more about this endpoint.
Please note that this endpoint assumes trustworthy input for the most part. By default, the endpoint checks that
you do not exceed any quotas, do not sell any seats twice, or do not use any redeemed vouchers. However, it will not
complain about violation of any other availability constraints, such as violation of time frames or minimum/maximum
amounts of either your product or event. Bundled products will not be added in automatically and fees will not be
calculated automatically.
.. sourcecode:: http
POST /api/v1/organizers/democon/events/3vjrh/orders/ HTTP/1.1
Host: test.pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
Authorization: …
{
"email": "dummy@example.org",
"locale": "en",
"sales_channel": "web",
"payment_provider": "banktransfer",
"invoice_address": {
"is_business": false,
"company": "Sample company",
"name_parts": {"full_name": "John Doe"},
"street": "Sesam Street 12",
"zipcode": "12345",
"city": "Sample City",
"country": "US",
"state": "NY",
"internal_reference": "",
"vat_id": ""
},
"positions": [
{
"item": 21,
"variation": null,
"attendee_name_parts": {
"full_name": "Peter"
},
"answers": [
{
"question": 1,
"answer": "23",
"options": []
}
],
"subevent": null
}
],
"fees": []
}
You will be returned a full order object that you can inspect, store, or use to build emails or confirmation pages for
the user. If you don't want to do that yourself, it will also contain the URL to our confirmation page in the ``url``
attribute. If you pass the ``"send_mail": true`` option, pretix will also send order confirmations for you.
Handling payments yourself
--------------------------
If you want to handle payments in your application, you can either just create the orders with status "paid" or you can
create them in "pending" state (the default) and later confirm the payment. We strongly advise to use the payment
provider ``"manual"`` in this case to avoid interference with payment code with pretix.
However, it is often unfeasible to implement the payment process yourself, and it also requires you to give up a
lot of pretix functionality, such as automatic refunds. Therefore, it is also possible to utilize pretix' native
payment process even in this case:
Using pretix payment providers
------------------------------
If you passed a ``payment_provider`` during order creation above, pretix will have created a payment object with state
``created`` that you can see in the returned order object. This payment object will have an attribute ``payment_url``
that you can use to let the user pay. For example, you could link or redirect to this page.
If you want the user to return to your application after the payment is complete, you can pass a query parameter
``return_url``. To prepare your event for this, open your event in the pretix backend and go to "Settings", then
"Plugins". Enable the plugin "Redirection from order page". Then, go to the new page "Settings", then "Redirection".
Enter the base URL of your web application. This will allow you to redirect to pages under this base URL later on.
For example, if you want users to be redirected to ``https://example.org/order/return?tx_id=1234``, you could now
either enter ``https://example.org`` or ``https://example.org/order/``.
The user will be redirected back to your page instead of pretix' order confirmation page after the payment,
**regardless of whether it was successful or not**. Make sure you use our API to check if the payment actually
worked! Your final URL could look like this::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234
You can also embed this page in an ``<iframe>`` instead. Note, however, that this causes problems with some payment
methods such as PayPal which do not allow being opened in an iframe. pretix can partly work around these issues by
opening a new window, but will only to so if you also append an ``iframe=1`` parameter to the URL::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/123/?return_url=https%3A%2F%2Fexample.org%2Forder%2Freturn%3Ftx_id%3D1234&iframe=1
If you did **not** pass a payment method since you want us to ask the user which payment method they want to use, you
need to construct the URL from the ``url`` attribute of the order and the sub-path ``pay/change```. For example, you
would end up with the following URL::
https://test.pretix.eu/democon/3vjrh/order/NSLEZ/ujbrnsjzbq4dzhck/pay/change
Of course, you can also use the ``iframe`` and ``return_url`` parameters here.
Optional: Cart reservations
---------------------------
Creating orders is an atomic operation: The order is either created as a whole or not at all. However, pretix'
built-in checkout automatically reserves tickets in a user's cart for a configurable amount of time to ensure users
will actually get their tickets once they started entering all their details. If you want a similar behavior in your
application, you need to create :ref:`rest-carts` through the API.
When creating your order, you can pass a ``consume_carts`` parameter with the cart ID(s) of your user. This way, the
quota reserved by the cart will be credited towards the order and the carts will be destroyed if (and only if) the
order creation succeeds.
Cart creation is currently even more limited than the order creation endpoints, as cart creation currently does not
support vouchers or automatic price calculation. If you require these features, please get in touch with us.

11
doc/api/guides/index.rst Normal file
View File

@@ -0,0 +1,11 @@
.. _`rest-api-guides`:
API Usage Guides
================
This part of the documentation contains how-to guides on some special use cases of our API.
.. toctree::
:maxdepth: 2
custom_checkout

View File

@@ -18,3 +18,4 @@ in functionality over time.
resources/index resources/index
ratelimit ratelimit
webhooks webhooks
guides/index

View File

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

View File

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

View File

@@ -1,3 +1,5 @@
.. spelling:: checkin
Check-in lists Check-in lists
============== ==============
@@ -27,6 +29,7 @@ subevent integer ID of the date
position_count integer Number of tickets that match this list (read-only). position_count integer Number of tickets that match this list (read-only).
checkin_count integer Number of check-ins performed on this list (read-only). checkin_count integer Number of check-ins performed on this list (read-only).
include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state. include_pending boolean If ``true``, the check-in list also contains tickets from orders in pending state.
auto_checkin_sales_channels list of strings All items on the check-in list will be automatically marked as checked-in when purchased through any of the listed sales channels.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.10 .. versionchanged:: 1.10
@@ -41,6 +44,10 @@ include_pending boolean If ``true``, th
The ``include_pending`` field has been added. The ``include_pending`` field has been added.
.. versionchanged:: 3.2
The ``auto_checkin_sales_channels`` field has been added.
Endpoints Endpoints
--------- ---------
@@ -81,7 +88,10 @@ Endpoints
"all_products": true, "all_products": true,
"limit_products": [], "limit_products": [],
"include_pending": false, "include_pending": false,
"subevent": null "subevent": null,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
] ]
} }
@@ -122,7 +132,10 @@ Endpoints
"all_products": true, "all_products": true,
"limit_products": [], "limit_products": [],
"include_pending": false, "include_pending": false,
"subevent": null "subevent": null,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to fetch :param organizer: The ``slug`` field of the organizer to fetch
@@ -209,13 +222,16 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/checkinlists/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"name": "VIP entry", "name": "VIP entry",
"all_products": false, "all_products": false,
"limit_products": [1, 2], "limit_products": [1, 2],
"subevent": null "subevent": null,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
**Example response**: **Example response**:
@@ -234,7 +250,10 @@ Endpoints
"all_products": false, "all_products": false,
"limit_products": [1, 2], "limit_products": [1, 2],
"include_pending": false, "include_pending": false,
"subevent": null "subevent": null,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer of the event/item to create a list for :param organizer: The ``slug`` field of the organizer of the event/item to create a list for
@@ -283,7 +302,10 @@ Endpoints
"all_products": false, "all_products": false,
"limit_products": [1, 2], "limit_products": [1, 2],
"include_pending": false, "include_pending": false,
"subevent": null "subevent": null,
"auto_checkin_sales_channels": [
"pretixpos"
]
} }
:param organizer: The ``slug`` field of the organizer to modify :param organizer: The ``slug`` field of the organizer to modify
@@ -342,6 +364,11 @@ Order position endpoints
``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint ``ignore_status`` filter. The ``attendee_name`` field is now "smart" (see below) and the redemption endpoint
returns ``400`` instead of ``404`` on tickets which are known but not paid. returns ``400`` instead of ``404`` on tickets which are known but not paid.
.. versionchanged:: 3.2
The ``checkins`` dict now also contains a ``auto_checked_in`` value to indicate if the check-in has been performed
automatically by the system.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/checkinlists/(list)/positions/
Returns a list of all order positions within a given event. The result is the same as Returns a list of all order positions within a given event. The result is the same as
@@ -396,10 +423,12 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
"datetime": "2017-12-25T12:45:23Z" "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": true
} }
], ],
"answers": [ "answers": [
@@ -505,10 +534,12 @@ Order position endpoints
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 1, "list": 1,
"datetime": "2017-12-25T12:45:23Z" "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": true
} }
], ],
"answers": [ "answers": [
@@ -546,6 +577,8 @@ Order position endpoints
you do not implement question handling in your user interface, you **must** you do not implement question handling in your user interface, you **must**
set this to ``false``. In that case, questions will just be ignored. Defaults set this to ``false``. In that case, questions will just be ignored. Defaults
to ``true``. to ``true``.
:<json boolean canceled_supported: When this parameter is set to ``true``, the response code ``canceled`` may be
returned. Otherwise, canceled orders will return ``unpaid``.
:<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used. :<json datetime datetime: Specifies the datetime of the check-in. If not supplied, the current time will be used.
:<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required :<json boolean force: Specifies that the check-in should succeed regardless of previous check-ins or required
questions that have not been filled. Defaults to ``false``. questions that have not been filled. Defaults to ``false``.
@@ -574,6 +607,7 @@ Order position endpoints
"nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA", "nonce": "Pvrk50vUzQd0DhdpNRL4I4OcXsvg70uA",
"datetime": null, "datetime": null,
"questions_supported": true, "questions_supported": true,
"canceled_supported": true,
"answers": { "answers": {
"4": "XS" "4": "XS"
} }
@@ -657,7 +691,9 @@ Order position endpoints
Possible error reasons: Possible error reasons:
* ``unpaid`` - Ticket is not paid for or has been refunded * ``unpaid`` - Ticket is not paid for
* ``canceled`` Ticket is canceled or expired. This reason is only sent when your request sets
``canceled_supported`` to ``true``, otherwise these orders return ``unpaid``.
* ``already_redeemed`` - Ticket already has been redeemed * ``already_redeemed`` - Ticket already has been redeemed
* ``product`` - Tickets with this product may not be scanned at this device * ``product`` - Tickets with this product may not be scanned at this device

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -53,7 +53,9 @@ invoice_address object Invoice address
├ street string Customer street ├ street string Customer street
├ zipcode string Customer ZIP code ├ zipcode string Customer ZIP code
├ city string Customer city ├ city string Customer city
├ country string Customer country ├ country string Customer country code
├ state string Customer state (ISO 3166-2 code). Only supported in
AU, BR, CA, CN, MY, MX, and US.
├ internal_reference string Customer's internal reference to be printed on the invoice ├ internal_reference string Customer's internal reference to be printed on the invoice
├ vat_id string Customer VAT ID ├ vat_id string Customer VAT ID
└ vat_id_validated string ``true``, if the VAT ID has been validated against the └ vat_id_validated string ``true``, if the VAT ID has been validated against the
@@ -82,6 +84,7 @@ require_approval boolean If ``true`` and
needs approval by an organizer before it can needs approval by an organizer before it can
continue. If ``true`` and the order is canceled, continue. If ``true`` and the order is canceled,
this order has been denied by the event organizer. this order has been denied by the event organizer.
url string The full URL to the order confirmation page
payments list of objects List of payment processes (see below) payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below) refunds list of objects List of refund processes (see below)
last_modified datetime Last modification of this object last_modified datetime Last modification of this object
@@ -137,6 +140,12 @@ last_modified datetime Last modificati
The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders. The ``testmode`` attribute has been added and ``DELETE`` has been implemented for orders.
.. versionchanged:: 3.1:
The ``invoice_address.state`` and ``url`` attributes have been added. When creating orders through the API,
vouchers are now supported and many fields are now optional.
.. _order-position-resource: .. _order-position-resource:
Order position resource Order position resource
@@ -166,7 +175,8 @@ subevent integer ID of the date
pseudonymization_id string A random ID, e.g. for use in lead scanning apps pseudonymization_id string A random ID, e.g. for use in lead scanning apps
checkins list of objects List of check-ins with this ticket checkins list of objects List of check-ins with this ticket
├ list integer Internal ID of the check-in list ├ list integer Internal ID of the check-in list
datetime datetime Time of check-in datetime datetime Time of check-in
└ auto_checked_in boolean Indicates if this check-in been performed automatically by the system
downloads list of objects List of ticket download options downloads list of objects List of ticket download options
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``) ├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
└ url string Download URL └ url string Download URL
@@ -176,6 +186,10 @@ answers list of objects Answers to user
├ question_identifier string The question's ``identifier`` field ├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types) ├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s └ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
seat objects The assigned seat. Can be ``null``.
├ id integer Internal ID of the seat instance
├ name string Human-readable seat name
└ seat_guid string Identifier of the seat within the seating plan
pdf_data object Data object required for ticket PDF generation. By default, pdf_data object Data object required for ticket PDF generation. By default,
this field is missing. It will be added only if you add the this field is missing. It will be added only if you add the
``pdf_data=true`` query parameter to your request. ``pdf_data=true`` query parameter to your request.
@@ -197,6 +211,14 @@ pdf_data object Data object req
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added. The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
.. versionchanged:: 3.0
The attribute ``seat`` has been added.
.. versionchanged:: 3.2
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. _order-payment-resource: .. _order-payment-resource:
Order payment resource Order payment resource
@@ -213,13 +235,27 @@ amount money (string) Payment amount
created datetime Date and time of creation of this payment created datetime Date and time of creation of this payment
payment_date datetime Date and time of completion of this payment (or ``null``) payment_date datetime Date and time of completion of this payment (or ``null``)
provider string Identification string of the payment provider provider string Identification string of the payment provider
payment_url string The URL where an user can continue with the payment (or ``null``)
details object Payment-specific information. This is a dictionary
with various fields that can be different between
payment providers, versions, payment states, etc. If
you read this field, you always need to be able to
deal with situations where values that you expect are
missing. Mostly, the field contains various IDs that
can be used for matching with other systems. If a
payment provider does not implement this feature,
the object is empty.
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 2.0 .. versionchanged:: 2.0
This resource has been added. This resource has been added.
.. _order-payment-resource: .. versionchanged:: 3.1
The attributes ``payment_url`` and ``details`` have been added.
.. _order-refund-resource:
Order refund resource Order refund resource
--------------------- ---------------------
@@ -280,6 +316,7 @@ List of all orders
"status": "p", "status": "p",
"testmode": false, "testmode": false,
"secret": "k24fiuwvu8kxz3y1", "secret": "k24fiuwvu8kxz3y1",
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
"email": "tester@example.org", "email": "tester@example.org",
"locale": "en", "locale": "en",
"sales_channel": "web", "sales_channel": "web",
@@ -302,7 +339,8 @@ List of all orders
"street": "Test street 12", "street": "Test street 12",
"zipcode": "12345", "zipcode": "12345",
"city": "Testington", "city": "Testington",
"country": "Testikistan", "country": "DE",
"state": "",
"internal_reference": "", "internal_reference": "",
"vat_id": "EU123456789", "vat_id": "EU123456789",
"vat_id_validated": false "vat_id_validated": false
@@ -328,10 +366,12 @@ List of all orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z" "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -364,6 +404,8 @@ List of all orders
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
@@ -422,6 +464,7 @@ Fetching individual orders
"status": "p", "status": "p",
"testmode": false, "testmode": false,
"secret": "k24fiuwvu8kxz3y1", "secret": "k24fiuwvu8kxz3y1",
"url": "https://test.pretix.eu/dummy/dummy/order/ABC12/k24fiuwvu8kxz3y1/",
"email": "tester@example.org", "email": "tester@example.org",
"locale": "en", "locale": "en",
"sales_channel": "web", "sales_channel": "web",
@@ -444,7 +487,8 @@ Fetching individual orders
"street": "Test street 12", "street": "Test street 12",
"zipcode": "12345", "zipcode": "12345",
"city": "Testington", "city": "Testington",
"country": "Testikistan", "country": "DE",
"state": "",
"internal_reference": "", "internal_reference": "",
"vat_id": "EU123456789", "vat_id": "EU123456789",
"vat_id_validated": false "vat_id_validated": false
@@ -470,10 +514,12 @@ Fetching individual orders
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z" "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -506,6 +552,8 @@ Fetching individual orders
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }
], ],
@@ -681,6 +729,8 @@ Deleting orders
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted. :statuscode 403: The requested organizer/event does not exist **or** you have no permission to delete this resource **or** the order may not be deleted.
:statuscode 404: The requested order does not exist. :statuscode 404: The requested order does not exist.
.. _rest-orders-create:
Creating orders Creating orders
--------------- ---------------
@@ -688,8 +738,6 @@ Creating orders
Creates a new order. Creates a new order.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning:: .. warning::
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend, This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend,
@@ -708,23 +756,17 @@ Creating orders
* does not validate the number of items per order or the number of times an item can be included in an order * does not validate the number of items per order or the number of times an item can be included in an order
* does not validate any requirements related to add-on products * does not validate any requirements related to add-on products and does not add bundled products automatically
* does not check or calculate prices but believes any prices you send * does not check prices but believes any prices you send
* does not support the redemption of vouchers
* does not prevent you from buying items that can only be bought with a voucher * does not prevent you from buying items that can only be bought with a voucher
* does not calculate fees * does not calculate fees automatically
* does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping * does not allow to pass data to plugins and will therefore cause issues with some plugins like the shipping
module module
* does not send order confirmations via email
* does not support reverse charge taxation
* does not support file upload questions * does not support file upload questions
You can supply the following fields of the resource: You can supply the following fields of the resource:
@@ -737,14 +779,14 @@ Creating orders
then call the ``mark_paid`` API method. then call the ``mark_paid`` API method.
* ``testmode`` (optional) Defaults to ``false`` * ``testmode`` (optional) Defaults to ``false``
* ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the * ``consume_carts`` (optional) A list of cart IDs. All cart positions with these IDs will be deleted if the
order creation is successful. Any quotas that become free by this operation will be credited to your order order creation is successful. Any quotas or seats that become free by this operation will be credited to your order
creation. creation.
* ``email`` * ``email``
* ``locale`` * ``locale``
* ``sales_channel`` * ``sales_channel``
* ``payment_provider`` The identifier of the payment provider set for this order. This needs to be an existing * ``payment_provider`` (optional) The identifier of the payment provider set for this order. This needs to be an
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` for all existing payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"``
orders you create as paid. for all orders you create as paid.
* ``payment_info`` (optional) You can pass a nested JSON object that will be set as the internal ``info`` * ``payment_info`` (optional) You can pass a nested JSON object that will be set as the internal ``info``
value of the payment object that will be created. How this value is handled is up to the payment provider and you value of the payment object that will be created. How this value is handled is up to the payment provider and you
should only use this if you know the specific payment provider in detail. Please keep in mind that the payment should only use this if you know the specific payment provider in detail. Please keep in mind that the payment
@@ -762,16 +804,22 @@ Creating orders
* ``zipcode`` * ``zipcode``
* ``city`` * ``city``
* ``country`` * ``country``
* ``state``
* ``internal_reference`` * ``internal_reference``
* ``vat_id`` * ``vat_id``
* ``vat_id_validated`` (optional) If you need support for reverse charge (rarely the case), you need to check
yourself if the passed VAT ID is a valid EU VAT ID. In that case, set this to ``true``. Only valid VAT IDs will
trigger reverse charge taxation. Don't forget to set ``is_business`` as well!
* ``positions`` * ``positions``
* ``positionid`` (optional, see below) * ``positionid`` (optional, see below)
* ``item`` * ``item``
* ``variation`` * ``variation``
* ``price`` * ``price`` (optional, if set to ``null`` or missing the price will be computed from the given product)
* ``seat`` (The ``seat_guid`` attribute of a seat. Required when the specified ``item`` requires a seat, otherwise must be ``null``.)
* ``attendee_name`` **or** ``attendee_name_parts`` * ``attendee_name`` **or** ``attendee_name_parts``
* ``voucher`` (optional, the ``code`` attribute of a valid voucher)
* ``attendee_email`` * ``attendee_email``
* ``secret`` (optional) * ``secret`` (optional)
* ``addon_to`` (optional, see below) * ``addon_to`` (optional, see below)
@@ -791,6 +839,8 @@ Creating orders
* ``tax_rule`` * ``tax_rule``
* ``force`` (optional). If set to ``true``, quotas will be ignored. * ``force`` (optional). If set to ``true``, quotas will be ignored.
* ``send_mail`` (optional). If set to ``true``, the same emails will be sent as for a regular order. Defaults to
``false``.
If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually If you want to use add-on products, you need to set the ``positionid`` fields of all positions manually
to incrementing integers starting with ``1``. Then, you can reference one of these to incrementing integers starting with ``1``. Then, you can reference one of these
@@ -828,6 +878,7 @@ Creating orders
"zipcode": "12345", "zipcode": "12345",
"city": "Sample City", "city": "Sample City",
"country": "UK", "country": "UK",
"state": "",
"internal_reference": "", "internal_reference": "",
"vat_id": "" "vat_id": ""
}, },
@@ -851,7 +902,7 @@ Creating orders
], ],
"subevent": null "subevent": null
} }
], ]
} }
**Example response**: **Example response**:
@@ -1242,6 +1293,11 @@ List of all order positions
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
``pseudonymization_id``. ``pseudonymization_id``.
.. versionchanged:: 3.2
The value ``auto_checked_in`` has been added to the ``checkins``-attribute.
.. note:: Individually canceled order positions are currently not visible via the API at all. .. note:: Individually canceled order positions are currently not visible via the API at all.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/ .. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
@@ -1287,12 +1343,14 @@ List of all order positions
"tax_value": "0.00", "tax_value": "0.00",
"secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w", "secret": "z3fsn8jyufm5kpk768q69gkbyr5f4h6w",
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z" "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -1389,10 +1447,12 @@ Fetching individual positions
"addon_to": null, "addon_to": null,
"subevent": null, "subevent": null,
"pseudonymization_id": "MQLJvANO3B", "pseudonymization_id": "MQLJvANO3B",
"seat": null,
"checkins": [ "checkins": [
{ {
"list": 44, "list": 44,
"datetime": "2017-12-25T12:45:23Z" "datetime": "2017-12-25T12:45:23Z",
"auto_checked_in": false
} }
], ],
"answers": [ "answers": [
@@ -1535,6 +1595,8 @@ Order payment endpoints
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }
] ]
@@ -1575,6 +1637,8 @@ Order payment endpoints
"amount": "23.00", "amount": "23.00",
"created": "2017-12-01T10:00:00Z", "created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z", "payment_date": "2017-12-04T12:13:12Z",
"payment_url": null,
"details": {},
"provider": "banktransfer" "provider": "banktransfer"
} }

View File

@@ -41,6 +41,8 @@ ask_during_checkin boolean If ``true``, th
the ticket instead. the ticket instead.
hidden boolean If ``true``, the question will only be shown in the hidden boolean If ``true``, the question will only be shown in the
backend. backend.
print_on_invoice boolean If ``true``, the question will only be shown on
invoices.
options list of objects In case of question type ``C`` or ``M``, this lists the options list of objects In case of question type ``C`` or ``M``, this lists the
available objects. Only writable during creation, available objects. Only writable during creation,
use separate endpoint to modify this later. use separate endpoint to modify this later.
@@ -54,11 +56,12 @@ dependency_question integer Internal ID of
this attribute is set to the value given in this attribute is set to the value given in
``dependency_value``. This cannot be combined with ``dependency_value``. This cannot be combined with
``ask_during_checkin``. ``ask_during_checkin``.
dependency_value string The value ``dependency_question`` needs to be set to. dependency_values list of strings If ``dependency_question`` is set to a boolean
If ``dependency_question`` is set to a boolean question, this should be ``["True"]`` or ``["False"]``.
question, this should be ``"true"`` or ``"false"``. Otherwise, it should be a list of ``identifier`` values
Otherwise, it should be the ``identifier`` of a of question options.
question option. dependency_value string An old version of ``dependency_values`` that only allows
for one value. **Deprecated.**
===================================== ========================== ======================================================= ===================================== ========================== =======================================================
.. versionchanged:: 1.12 .. versionchanged:: 1.12
@@ -75,6 +78,14 @@ dependency_value string The value ``dep
The attribute ``hidden`` and the question type ``CC`` have been added. The attribute ``hidden`` and the question type ``CC`` have been added.
.. versionchanged:: 3.0
The attribute ``dependency_values`` has been added.
.. versionchanged:: 3.1
The attribute ``print_on_invoice`` has been added.
Endpoints Endpoints
--------- ---------
@@ -118,8 +129,10 @@ Endpoints
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false, "hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -186,8 +199,10 @@ Endpoints
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false, "hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -228,7 +243,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1 POST /api/v1/organizers/bigevents/events/sampleconf/questions/ HTTP/1.1
Host: pretix.eu Host: pretix.eu
Accept: application/json, text/javascript Accept: application/json, text/javascript
Content: application/json Content-Type: application/json
{ {
"question": {"en": "T-Shirt size"}, "question": {"en": "T-Shirt size"},
@@ -238,8 +253,9 @@ Endpoints
"position": 1, "position": 1,
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false, "hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_values": [],
"options": [ "options": [
{ {
"answer": {"en": "S"} "answer": {"en": "S"}
@@ -272,8 +288,10 @@ Endpoints
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false, "hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,
@@ -344,8 +362,10 @@ Endpoints
"identifier": "WY3TP9SL", "identifier": "WY3TP9SL",
"ask_during_checkin": false, "ask_during_checkin": false,
"hidden": false, "hidden": false,
"print_on_invoice": false,
"dependency_question": null, "dependency_question": null,
"dependency_value": null, "dependency_value": null,
"dependency_values": [],
"options": [ "options": [
{ {
"id": 1, "id": 1,

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -12,6 +12,7 @@ Contents:
payment payment
payment_2.0 payment_2.0
email email
placeholder
invoice invoice
shredder shredder
customview customview

View File

@@ -108,6 +108,8 @@ The provider class
.. automethod:: execute_refund .. automethod:: execute_refund
.. automethod:: api_payment_details
.. automethod:: shred_payment_info .. automethod:: shred_payment_info
.. autoattribute:: is_implicit .. autoattribute:: is_implicit

View File

@@ -0,0 +1,79 @@
.. highlight:: python
:linenothreshold: 5
Writing an HTML e-mail placeholder plugin
=========================================
An email placeholder is a dynamic value that pretix users can use in their email templates.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
Placeholder registration
------------------------
The placeholder API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available email placeholders. Your plugin
should listen for this signal and return an instance of a subclass of ``pretix.base.email.BaseMailTextPlaceholder``::
from django.dispatch import receiver
from pretix.base.signals import register_mail_placeholders
@receiver(register_mail_placeholders, dispatch_uid="placeholder_custom")
def register_mail_renderers(sender, **kwargs):
from .email import MyPlaceholderClass
return MyPlaceholder()
Context mechanism
-----------------
Emails are sent in different "contexts" within pretix. For example, many emails are sent in the
the context of an order, but some are not, such as the notification of a waiting list voucher.
Not all placeholders make sense in every email, and placeholders usually depend some parameters
themselves, such as the ``Order`` object. Therefore, placeholders are expected to explicitly declare
what values they depend on and they will only be available in an email if all those dependencies are
met. Currently, placeholders can depend on the following context parameters:
* ``event``
* ``order``
* ``position``
* ``waiting_list_entry``
* ``invoice_address``
* ``payment``
There are a few more that are only to be used internally but not by plugins.
The placeholder class
---------------------
.. class:: pretix.base.email.BaseMailTextPlaceholder
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: required_context
This is an abstract attribute, you **must** override this!
.. automethod:: render
This is an abstract method, you **must** implement this!
.. automethod:: render_sample
This is an abstract method, you **must** implement this!
Helper class for simple placeholders
------------------------------------
pretix ships with a helper class that makes it easy to provide placeholders based on simple
functions::
placeholder = SimpleFunctionalMailTextPlaceholder(
'code', ['order'], lambda order: order.code, sample='F8VVL'
)

View File

@@ -35,9 +35,9 @@ The shredder class
.. class:: pretix.base.shredder.BaseDataShredder .. class:: pretix.base.shredder.BaseDataShredder
The central object of each invoice renderer is the subclass of ``BaseInvoiceRenderer``. The central object of each data shredder is the subclass of ``BaseDataShredder``.
.. py:attribute:: BaseInvoiceRenderer.event .. py:attribute:: BaseDataShredder.event
The default constructor sets this property to the event we are currently The default constructor sets this property to the event we are currently
working for. working for.

View File

@@ -65,9 +65,7 @@ Then, create the local database::
python manage.py migrate python manage.py migrate
A first user with username ``admin@localhost`` and password ``admin`` will be automatically A first user with username ``admin@localhost`` and password ``admin`` will be automatically
created. If you want to generate more test data, run:: created.
python make_testdata.py
If you want to see pretix in a different language than English, you have to compile our language If you want to see pretix in a different language than English, you have to compile our language
files:: files::
@@ -83,8 +81,7 @@ To run the local development webserver, execute::
and head to http://localhost:8000/ and head to http://localhost:8000/
As we did not implement an overall front page yet, you need to go directly to As we did not implement an overall front page yet, you need to go directly to
http://localhost:8000/control/ for the admin view or, if you imported the test http://localhost:8000/control/ for the admin view.
data as suggested above, to the event page at http://localhost:8000/bigevents/2019/
.. note:: If you want the development server to listen on a different interface or .. note:: If you want the development server to listen on a different interface or
port (for example because you develop on `pretixdroid`_), you can check port (for example because you develop on `pretixdroid`_), you can check

View File

@@ -36,13 +36,17 @@ eu
filename filename
filesystem filesystem
fontawesome fontawesome
formset
formsets
frontend frontend
frontpage frontpage
gettext gettext
gunicorn gunicorn
guid
hardcoded hardcoded
hostname hostname
idempotency idempotency
iframe
incrementing incrementing
inofficial inofficial
invalidations invalidations
@@ -101,6 +105,7 @@ screenshot
scss scss
searchable searchable
selectable selectable
serializable
serializers serializers
serializers serializers
sexualized sexualized
@@ -134,6 +139,7 @@ versa
versioning versioning
viewset viewset
viewsets viewsets
waitinglist
webhook webhook
webhooks webhooks
webserver webserver

View File

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

View File

@@ -143,6 +143,11 @@ You can see an example here:
</div> </div>
</noscript> </noscript>
You can filter events by meta data attributes. You can create those attributes in your order profile and set their values in both event and series date
settings. For example, if you set up a meta data property called "Promoted" that you set to "Yes" on some events, you can pass a filter like this::
<pretix-widget event="https://pretix.eu/demo/series/" style="list" filter="attr[Promoted]=Yes"></pretix-widget>
pretix Button pretix Button
------------- -------------
@@ -269,6 +274,9 @@ Hosted or pretix Enterprise are active, you can pass the following fields:
}; };
</script> </script>
In some combinations with Google Tag Manager, the widget does not load this way. In this case, try replacing
``tracker.get('clientId')`` with ``ga.getAll()[0].get('clientId')``.
.. versionchanged:: 2.3 .. versionchanged:: 2.3

View File

@@ -22,3 +22,5 @@ recursive-include pretix/plugins/ticketoutputpdf/templates *
recursive-include pretix/plugins/ticketoutputpdf/static * recursive-include pretix/plugins/ticketoutputpdf/static *
recursive-include pretix/plugins/badges/templates * recursive-include pretix/plugins/badges/templates *
recursive-include pretix/plugins/badges/static * recursive-include pretix/plugins/badges/static *
recursive-include pretix/plugins/returnurl/templates *
recursive-include pretix/plugins/returnurl/static *

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,6 +3,7 @@ from rest_framework import serializers
from rest_framework.exceptions import ValidationError from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.channels import get_all_sales_channels
from pretix.base.models import CheckinList from pretix.base.models import CheckinList
@@ -13,7 +14,7 @@ class CheckinListSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = CheckinList model = CheckinList
fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count', fields = ('id', 'name', 'all_products', 'limit_products', 'subevent', 'checkin_count', 'position_count',
'include_pending') 'include_pending', 'auto_checkin_sales_channels')
def validate(self, data): def validate(self, data):
data = super().validate(data) data = super().validate(data)
@@ -35,4 +36,8 @@ class CheckinListSerializer(I18nAwareModelSerializer):
if full_data.get('subevent'): if full_data.get('subevent'):
raise ValidationError(_('The subevent does not belong to this event.')) raise ValidationError(_('The subevent does not belong to this event.'))
for channel in full_data.get('auto_checkin_sales_channels') or []:
if channel not in get_all_sales_channels():
raise ValidationError(_('Unknown sales channel.'))
return data return data

View File

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

View File

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

View File

@@ -2,6 +2,8 @@ import json
from collections import Counter from collections import Counter
from decimal import Decimal from decimal import Decimal
import pycountry
from django.db.models import F, Q
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy from django.utils.translation import ugettext_lazy
from django_countries.fields import Country from django_countries.fields import Country
@@ -15,13 +17,17 @@ from pretix.base.channels import get_all_sales_channels
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import ( from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order, Checkin, Invoice, InvoiceAddress, InvoiceLine, Item, ItemVariation, Order,
OrderPosition, Question, QuestionAnswer, SubEvent, OrderPosition, Question, QuestionAnswer, Seat, SubEvent, Voucher,
) )
from pretix.base.models.orders import ( from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund, CartPosition, OrderFee, OrderPayment, OrderRefund,
) )
from pretix.base.pdf import get_variables from pretix.base.pdf import get_variables
from pretix.base.services.cart import error_messages
from pretix.base.services.pricing import get_price
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
from pretix.base.signals import register_ticket_outputs from pretix.base.signals import register_ticket_outputs
from pretix.multidomain.urlreverse import build_absolute_uri
class CompatibleCountryField(serializers.Field): class CompatibleCountryField(serializers.Field):
@@ -42,8 +48,8 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = InvoiceAddress model = InvoiceAddress
fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country', fields = ('last_modified', 'is_business', 'company', 'name', 'name_parts', 'street', 'zipcode', 'city', 'country',
'vat_id', 'vat_id_validated', 'internal_reference') 'state', 'vat_id', 'vat_id_validated', 'internal_reference')
read_only_fields = ('last_modified', 'vat_id_validated') read_only_fields = ('last_modified',)
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -58,6 +64,24 @@ class InvoiceAddressSerializer(I18nAwareModelSerializer):
) )
if data.get('name_parts') and '_scheme' not in data.get('name_parts'): if data.get('name_parts') and '_scheme' not in data.get('name_parts'):
data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme data['name_parts']['_scheme'] = self.context['request'].event.settings.name_scheme
if data.get('country'):
if not pycountry.countries.get(alpha_2=data.get('country')):
raise ValidationError(
{'country': ['Invalid country code.']}
)
if data.get('state'):
cc = str(data.get('country') or self.instance.country or '')
if cc not in COUNTRIES_WITH_STATE_IN_ADDRESS:
raise ValidationError(
{'state': ['States are not supported in country "{}".'.format(cc)]}
)
if not pycountry.subdivisions.get(code=cc + '-' + data.get('state')):
raise ValidationError(
{'state': ['"{}" is not a known subdivision of the country "{}".'.format(data.get('state'), cc)]}
)
return data return data
@@ -71,6 +95,13 @@ class AnswerQuestionOptionsIdentifierField(serializers.Field):
return [o.identifier for o in instance.options.all()] return [o.identifier for o in instance.options.all()]
class InlineSeatSerializer(I18nAwareModelSerializer):
class Meta:
model = Seat
fields = ('id', 'name', 'seat_guid')
class AnswerSerializer(I18nAwareModelSerializer): class AnswerSerializer(I18nAwareModelSerializer):
question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True) question_identifier = AnswerQuestionIdentifierField(source='*', read_only=True)
option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True) option_identifiers = AnswerQuestionOptionsIdentifierField(source='*', read_only=True)
@@ -83,7 +114,7 @@ class AnswerSerializer(I18nAwareModelSerializer):
class CheckinSerializer(I18nAwareModelSerializer): class CheckinSerializer(I18nAwareModelSerializer):
class Meta: class Meta:
model = Checkin model = Checkin
fields = ('datetime', 'list') fields = ('datetime', 'list', 'auto_checked_in')
class OrderDownloadsField(serializers.Field): class OrderDownloadsField(serializers.Field):
@@ -166,12 +197,13 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
downloads = PositionDownloadsField(source='*') downloads = PositionDownloadsField(source='*')
order = serializers.SlugRelatedField(slug_field='code', read_only=True) order = serializers.SlugRelatedField(slug_field='code', read_only=True)
pdf_data = PdfDataSerializer(source='*') pdf_data = PdfDataSerializer(source='*')
seat = InlineSeatSerializer(read_only=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', fields = ('id', 'order', 'positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts',
'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins', 'attendee_email', 'voucher', 'tax_rate', 'tax_value', 'secret', 'addon_to', 'subevent', 'checkins',
'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data') 'downloads', 'answers', 'tax_rule', 'pseudonymization_id', 'pdf_data', 'seat')
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
@@ -254,10 +286,33 @@ class OrderFeeSerializer(I18nAwareModelSerializer):
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule') fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
class PaymentURLField(serializers.URLField):
def to_representation(self, instance: OrderPayment):
if instance.state != OrderPayment.PAYMENT_STATE_CREATED:
return None
return build_absolute_uri(self.context['event'], 'presale:event.order.pay', kwargs={
'order': instance.order.code,
'secret': instance.order.secret,
'payment': instance.pk,
})
class PaymentDetailsField(serializers.Field):
def to_representation(self, value: OrderPayment):
pp = value.payment_provider
if not pp:
return {}
return pp.api_payment_details(value)
class OrderPaymentSerializer(I18nAwareModelSerializer): class OrderPaymentSerializer(I18nAwareModelSerializer):
payment_url = PaymentURLField(source='*', allow_null=True, read_only=True)
details = PaymentDetailsField(source='*', allow_null=True, read_only=True)
class Meta: class Meta:
model = OrderPayment model = OrderPayment
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider') fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider', 'payment_url',
'details')
class OrderRefundSerializer(I18nAwareModelSerializer): class OrderRefundSerializer(I18nAwareModelSerializer):
@@ -268,6 +323,14 @@ class OrderRefundSerializer(I18nAwareModelSerializer):
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider') fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
class OrderURLField(serializers.URLField):
def to_representation(self, instance: Order):
return build_absolute_uri(self.context['event'], 'presale:event.order', kwargs={
'order': instance.code,
'secret': instance.secret,
})
class OrderSerializer(I18nAwareModelSerializer): class OrderSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer(allow_null=True) invoice_address = InvoiceAddressSerializer(allow_null=True)
positions = OrderPositionSerializer(many=True, read_only=True) positions = OrderPositionSerializer(many=True, read_only=True)
@@ -277,13 +340,15 @@ class OrderSerializer(I18nAwareModelSerializer):
refunds = OrderRefundSerializer(many=True, read_only=True) refunds = OrderRefundSerializer(many=True, read_only=True)
payment_date = OrderPaymentDateField(source='*', read_only=True) payment_date = OrderPaymentDateField(source='*', read_only=True)
payment_provider = OrderPaymentTypeField(source='*', read_only=True) payment_provider = OrderPaymentTypeField(source='*', read_only=True)
url = OrderURLField(source='*', read_only=True)
class Meta: class Meta:
model = Order model = Order
fields = ( fields = (
'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads', 'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel' 'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval', 'sales_channel',
'url'
) )
read_only_fields = ( read_only_fields = (
'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date', 'code', 'status', 'testmode', 'secret', 'datetime', 'expires', 'payment_date',
@@ -305,7 +370,6 @@ class OrderSerializer(I18nAwareModelSerializer):
# Even though all fields that shouldn't be edited are marked as read_only in the serializer # Even though all fields that shouldn't be edited are marked as read_only in the serializer
# (hopefully), we'll be extra careful here and be explicit about the model fields we update. # (hopefully), we'll be extra careful here and be explicit about the model fields we update.
update_fields = ['comment', 'checkin_attention', 'email', 'locale'] update_fields = ['comment', 'checkin_attention', 'email', 'locale']
print(validated_data)
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -323,7 +387,7 @@ class OrderSerializer(I18nAwareModelSerializer):
} }
try: try:
ia = instance.invoice_address ia = instance.invoice_address
if iadata.get('vat_id') != ia.vat_id: if iadata.get('vat_id') != ia.vat_id and 'vat_id_validated' not in iadata:
ia.vat_id_validated = False ia.vat_id_validated = False
self.fields['invoice_address'].update(ia, iadata) self.fields['invoice_address'].update(ia, iadata)
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
@@ -430,11 +494,16 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
addon_to = serializers.IntegerField(required=False, allow_null=True) addon_to = serializers.IntegerField(required=False, allow_null=True)
secret = serializers.CharField(required=False) secret = serializers.CharField(required=False)
attendee_name = serializers.CharField(required=False, allow_null=True) attendee_name = serializers.CharField(required=False, allow_null=True)
seat = serializers.CharField(required=False, allow_null=True)
price = serializers.DecimalField(required=False, allow_null=True, decimal_places=2,
max_digits=10)
voucher = serializers.SlugRelatedField(slug_field='code', queryset=Voucher.objects.none(),
required=False, allow_null=True)
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email', fields = ('positionid', 'item', 'variation', 'price', 'attendee_name', 'attendee_name_parts', 'attendee_email',
'secret', 'addon_to', 'subevent', 'answers') 'secret', 'addon_to', 'subevent', 'answers', 'seat', 'voucher')
def validate_secret(self, secret): def validate_secret(self, secret):
if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists(): if secret and OrderPosition.all.filter(order__event=self.context['event'], secret=secret).exists():
@@ -508,7 +577,7 @@ class CompatibleJSONField(serializers.JSONField):
class OrderCreateSerializer(I18nAwareModelSerializer): class OrderCreateSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer(required=False) invoice_address = InvoiceAddressSerializer(required=False)
positions = OrderPositionCreateSerializer(many=True, required=False) positions = OrderPositionCreateSerializer(many=True, required=True)
fees = OrderFeeCreateSerializer(many=True, required=False) fees = OrderFeeCreateSerializer(many=True, required=False)
status = serializers.ChoiceField(choices=( status = serializers.ChoiceField(choices=(
('n', Order.STATUS_PENDING), ('n', Order.STATUS_PENDING),
@@ -520,18 +589,26 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
min_length=5 min_length=5
) )
comment = serializers.CharField(required=False, allow_blank=True) comment = serializers.CharField(required=False, allow_blank=True)
payment_provider = serializers.CharField(required=True) payment_provider = serializers.CharField(required=False, allow_null=True)
payment_info = CompatibleJSONField(required=False) payment_info = CompatibleJSONField(required=False)
consume_carts = serializers.ListField(child=serializers.CharField(), required=False) consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
force = serializers.BooleanField(default=False, required=False) force = serializers.BooleanField(default=False, required=False)
payment_date = serializers.DateTimeField(required=False, allow_null=True) payment_date = serializers.DateTimeField(required=False, allow_null=True)
send_mail = serializers.BooleanField(default=False, required=False)
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.fields['positions'].child.fields['voucher'].queryset = self.context['event'].vouchers.all()
class Meta: class Meta:
model = Order model = Order
fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel', fields = ('code', 'status', 'testmode', 'email', 'locale', 'payment_provider', 'fees', 'comment', 'sales_channel',
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts', 'force') 'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'payment_date', 'consume_carts',
'force', 'send_mail')
def validate_payment_provider(self, pp): def validate_payment_provider(self, pp):
if pp is None:
return None
if pp not in self.context['event'].get_payment_providers(): if pp not in self.context['event'].get_payment_providers():
raise ValidationError('The given payment provider is not known.') raise ValidationError('The given payment provider is not known.')
return pp return pp
@@ -590,6 +667,9 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]} {'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
for p in data for p in data
] ]
else:
for i, p in enumerate(data):
p['positionid'] = i + 1
if any(errs): if any(errs):
raise ValidationError(errs) raise ValidationError(errs)
@@ -598,10 +678,11 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
def create(self, validated_data): def create(self, validated_data):
fees_data = validated_data.pop('fees') if 'fees' in validated_data else [] fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
positions_data = validated_data.pop('positions') if 'positions' in validated_data else [] positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
payment_provider = validated_data.pop('payment_provider') payment_provider = validated_data.pop('payment_provider', None)
payment_info = validated_data.pop('payment_info', '{}') payment_info = validated_data.pop('payment_info', '{}')
payment_date = validated_data.pop('payment_date', now()) payment_date = validated_data.pop('payment_date', now())
force = validated_data.pop('force', False) force = validated_data.pop('force', False)
self._send_mail = validated_data.pop('send_mail', False)
if 'invoice_address' in validated_data: if 'invoice_address' in validated_data:
iadata = validated_data.pop('invoice_address') iadata = validated_data.pop('invoice_address')
@@ -615,13 +696,16 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
ia = None ia = None
with self.context['event'].lock() as now_dt: with self.context['event'].lock() as now_dt:
quotadiff = Counter() free_seats = set()
seats_seen = set()
consume_carts = validated_data.pop('consume_carts', []) consume_carts = validated_data.pop('consume_carts', [])
delete_cps = [] delete_cps = []
quota_avail_cache = {} quota_avail_cache = {}
voucher_usage = Counter()
if consume_carts: if consume_carts:
for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts): for cp in CartPosition.objects.filter(
event=self.context['event'], cart_id__in=consume_carts, expires__gt=now()
):
quotas = (cp.variation.quotas.filter(subevent=cp.subevent) quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent)) if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
for quota in quotas: for quota in quotas:
@@ -629,14 +713,64 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
quota_avail_cache[quota] = list(quota.availability()) quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None: if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1 quota_avail_cache[quota][1] += 1
if cp.voucher:
voucher_usage[cp.voucher] -= 1
if cp.expires > now_dt: if cp.expires > now_dt:
quotadiff.subtract(quotas) if cp.seat:
free_seats.add(cp.seat)
delete_cps.append(cp) delete_cps.append(cp)
errs = [{} for p in positions_data] errs = [{} for p in positions_data]
for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
v = pos_data['voucher']
if not v.applies_to(pos_data['item'], pos_data.get('variation')):
errs[i]['voucher'] = [error_messages['voucher_invalid_item']]
continue
if v.subevent_id and pos_data.get('subevent').pk != v.subevent_id:
errs[i]['voucher'] = [error_messages['voucher_invalid_subevent']]
continue
if v.valid_until is not None and v.valid_until < now_dt:
errs[i]['voucher'] = [error_messages['voucher_expired']]
continue
voucher_usage[v] += 1
if voucher_usage[v] > 0:
redeemed_in_carts = CartPosition.objects.filter(
Q(voucher=pos_data['voucher']) & Q(event=self.context['event']) & Q(expires__gte=now_dt)
).exclude(pk__in=[cp.pk for cp in delete_cps])
v_avail = v.max_usages - v.redeemed - redeemed_in_carts.count()
if v_avail < voucher_usage[v]:
errs[i]['voucher'] = [
'The voucher has already been used the maximum number of times.'
]
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 not force: if not force:
for i, pos_data in enumerate(positions_data): for i, pos_data in enumerate(positions_data):
if pos_data.get('voucher'):
if pos_data['voucher'].allow_ignore_quota or pos_data['voucher'].block_quota:
continue
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent')) new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation') if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent'))) else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
@@ -658,8 +792,6 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
) )
] ]
quotadiff.update(new_quotas)
if any(errs): if any(errs):
raise ValidationError({'positions': errs}) raise ValidationError({'positions': errs})
@@ -667,38 +799,14 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
validated_data['locale'] = self.context['event'].settings.locale validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data) order = Order(event=self.context['event'], **validated_data)
order.set_expires(subevents=[p.get('subevent') for p in positions_data]) order.set_expires(subevents=[p.get('subevent') for p in positions_data])
order.total = sum([p['price'] for p in positions_data]) + sum([f['value'] for f in fees_data], Decimal('0.00'))
order.meta_info = "{}" order.meta_info = "{}"
order.total = Decimal('0.00')
order.save() order.save()
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.status = Order.STATUS_PAID
order.save()
order.payments.create(
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
payment_date=now()
)
elif payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
elif validated_data.get('status') == Order.STATUS_PAID:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
payment_date=payment_date,
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif payment_provider:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
state=OrderPayment.PAYMENT_STATE_CREATED
)
if ia: if ia:
ia.order = order ia.order = order
ia.save() ia.save()
pos_map = {} pos_map = {}
for pos_data in positions_data: for pos_data in positions_data:
answers_data = pos_data.pop('answers', []) answers_data = pos_data.pop('answers', [])
@@ -710,9 +818,27 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
} }
pos = OrderPosition(**pos_data) pos = OrderPosition(**pos_data)
pos.order = order pos.order = order
pos._calculate_tax()
if addon_to: if addon_to:
pos.addon_to = pos_map[addon_to] pos.addon_to = pos_map[addon_to]
if pos.price is None:
price = get_price(
item=pos.item,
variation=pos.variation,
voucher=pos.voucher,
custom_price=None,
subevent=pos.subevent,
addon_to=pos.addon_to,
invoice_address=ia,
)
pos.price = price.gross
pos.tax_rate = price.rate
pos.tax_value = price.tax
pos.tax_rule = pos.item.tax_rule
else:
pos._calculate_tax()
if pos.voucher:
Voucher.objects.filter(pk=pos.voucher.pk).update(redeemed=F('redeemed') + 1)
pos.save() pos.save()
pos_map[pos.positionid] = pos pos_map[pos.positionid] = pos
for answ_data in answers_data: for answ_data in answers_data:
@@ -722,12 +848,43 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
for cp in delete_cps: for cp in delete_cps:
cp.delete() cp.delete()
for fee_data in fees_data: for fee_data in fees_data:
f = OrderFee(**fee_data) f = OrderFee(**fee_data)
f.order = order f.order = order
f._calculate_tax() f._calculate_tax()
f.save() f.save()
order.total = sum([p.price for p in order.positions.all()]) + sum([f.value for f in order.fees.all()])
order.save(update_fields=['total'])
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.status = Order.STATUS_PAID
order.save()
order.payments.create(
amount=order.total, provider='free', state=OrderPayment.PAYMENT_STATE_CONFIRMED,
payment_date=now()
)
elif payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
elif validated_data.get('status') == Order.STATUS_PAID:
if not payment_provider:
raise ValidationError('You cannot create a paid order without a payment provider.')
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
payment_date=payment_date,
state=OrderPayment.PAYMENT_STATE_CONFIRMED
)
elif payment_provider:
order.payments.create(
amount=order.total,
provider=payment_provider,
info=payment_info,
state=OrderPayment.PAYMENT_STATE_CREATED
)
return order return order

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -11,6 +11,7 @@ from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext as _ from django.utils.translation import ugettext as _
from django_filters.rest_framework import DjangoFilterBackend, FilterSet from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from django_scopes import scopes_disabled
from rest_framework import mixins, serializers, status, viewsets from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import action from rest_framework.decorators import action
from rest_framework.exceptions import ( from rest_framework.exceptions import (
@@ -40,7 +41,8 @@ from pretix.base.services.invoices import (
) )
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import ( from pretix.base.services.orders import (
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order, OrderChangeManager, OrderError, _order_placed_email,
_order_placed_email_attendee, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded, extend_order, mark_order_expired, mark_order_refunded,
) )
from pretix.base.services.pricing import get_price from pretix.base.services.pricing import get_price
@@ -50,17 +52,17 @@ from pretix.base.signals import (
) )
from pretix.base.templatetags.money import money_filter from pretix.base.templatetags.money import money_filter
with scopes_disabled():
class OrderFilter(FilterSet):
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact')
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact')
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class OrderFilter(FilterSet): class Meta:
email = django_filters.CharFilter(field_name='email', lookup_expr='iexact') model = Order
code = django_filters.CharFilter(field_name='code', lookup_expr='iexact') fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
status = django_filters.CharFilter(field_name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(field_name='last_modified', lookup_expr='gte')
created_since = django_filters.IsoDateTimeFilter(field_name='datetime', lookup_expr='gte')
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale', 'testmode', 'require_approval']
class OrderViewSet(viewsets.ModelViewSet): class OrderViewSet(viewsets.ModelViewSet):
@@ -92,8 +94,8 @@ class OrderViewSet(viewsets.ModelViewSet):
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
'item__category', 'addon_to', 'item__category', 'addon_to', 'seat',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation')) Prefetch('addons', OrderPosition.objects.select_related('item', 'variation', 'seat'))
) )
) )
) )
@@ -102,7 +104,7 @@ class OrderViewSet(viewsets.ModelViewSet):
Prefetch( Prefetch(
'positions', 'positions',
OrderPosition.objects.all().prefetch_related( OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question', 'seat',
) )
) )
) )
@@ -430,6 +432,7 @@ class OrderViewSet(viewsets.ModelViewSet):
serializer.is_valid(raise_exception=True) serializer.is_valid(raise_exception=True)
with transaction.atomic(): with transaction.atomic():
self.perform_create(serializer) self.perform_create(serializer)
send_mail = serializer._send_mail
order = serializer.instance order = serializer.instance
serializer = OrderSerializer(order, context=serializer.context) serializer = OrderSerializer(order, context=serializer.context)
@@ -444,8 +447,42 @@ class OrderViewSet(viewsets.ModelViewSet):
(order.event.settings.get('invoice_generate') == 'True') or (order.event.settings.get('invoice_generate') == 'True') or
(order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID) (order.event.settings.get('invoice_generate') == 'paid' and order.status == Order.STATUS_PAID)
) and not order.invoices.last() ) and not order.invoices.last()
invoice = None
if gen_invoice: if gen_invoice:
generate_invoice(order, trigger_pdf=True) invoice = generate_invoice(order, trigger_pdf=True)
if send_mail:
payment = order.payments.last()
free_flow = (
payment and order.total == Decimal('0.00') and order.status == Order.STATUS_PAID and
not order.require_approval and payment.provider == "free"
)
if free_flow:
email_template = request.event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
email_attendees = request.event.settings.mail_send_order_free_attendee
email_attendees_template = request.event.settings.mail_text_order_free_attendee
else:
email_template = request.event.settings.mail_text_order_placed
log_entry = 'pretix.event.order.email.order_placed'
email_attendees = request.event.settings.mail_send_order_placed_attendee
email_attendees_template = request.event.settings.mail_text_order_placed_attendee
_order_placed_email(
request.event, order, payment.payment_provider if payment else None, email_template,
log_entry, invoice, payment
)
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(request.event, order, p, email_attendees_template, log_entry)
if not free_flow and order.status == Order.STATUS_PAID and payment:
payment._send_paid_mail(invoice, None, '')
if self.request.event.settings.mail_send_order_paid_attendee:
for p in order.positions.all():
if p.addon_to_id is None and p.attendee_email and p.attendee_email != order.email:
payment._send_paid_mail_attendee(p, None)
headers = self.get_success_headers(serializer.data) headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers) return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
@@ -531,48 +568,49 @@ class OrderViewSet(viewsets.ModelViewSet):
self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth) self.get_object().gracefully_delete(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
class OrderPositionFilter(FilterSet): with scopes_disabled():
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') class OrderPositionFilter(FilterSet):
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs') order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
attendee_name = django_filters.CharFilter(method='attendee_name_qs') has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
search = django_filters.CharFilter(method='search_qs') attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
def search_qs(self, queryset, name, value): def search_qs(self, queryset, name, value):
return queryset.filter( return queryset.filter(
Q(secret__istartswith=value) Q(secret__istartswith=value)
| Q(attendee_name_cached__icontains=value) | Q(attendee_name_cached__icontains=value)
| Q(addon_to__attendee_name_cached__icontains=value) | Q(addon_to__attendee_name_cached__icontains=value)
| Q(attendee_email__icontains=value) | Q(attendee_email__icontains=value)
| Q(addon_to__attendee_email__icontains=value) | Q(addon_to__attendee_email__icontains=value)
| Q(order__code__istartswith=value) | Q(order__code__istartswith=value)
| Q(order__invoice_address__name_cached__icontains=value) | Q(order__invoice_address__name_cached__icontains=value)
| Q(order__email__icontains=value) | Q(order__email__icontains=value)
) )
def has_checkin_qs(self, queryset, name, value): def has_checkin_qs(self, queryset, name, value):
return queryset.filter(checkins__isnull=not value) return queryset.filter(checkins__isnull=not value)
def attendee_name_qs(self, queryset, name, value): def attendee_name_qs(self, queryset, name, value):
return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value)) return queryset.filter(Q(attendee_name_cached__iexact=value) | Q(addon_to__attendee_name_cached__iexact=value))
class Meta: class Meta:
model = OrderPosition model = OrderPosition
fields = { fields = {
'item': ['exact', 'in'], 'item': ['exact', 'in'],
'variation': ['exact', 'in'], 'variation': ['exact', 'in'],
'secret': ['exact'], 'secret': ['exact'],
'order__status': ['exact', 'in'], 'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'], 'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in'], 'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'], 'pseudonymization_id': ['exact'],
'voucher__code': ['exact'], 'voucher__code': ['exact'],
'voucher': ['exact'], 'voucher': ['exact'],
} }
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet): class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none() queryset = OrderPosition.all.none()
filter_backends = (DjangoFilterBackend, OrderingFilter) filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid') ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',) ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
@@ -609,13 +647,13 @@ class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewS
) )
)) ))
).select_related( ).select_related(
'item', 'variation', 'item__category', 'addon_to' 'item', 'variation', 'item__category', 'addon_to', 'seat'
) )
else: else:
qs = qs.prefetch_related( qs = qs.prefetch_related(
'checkins', 'answers', 'answers__options', 'answers__question' 'checkins', 'answers', 'answers__options', 'answers__question'
).select_related( ).select_related(
'item', 'order', 'order__event', 'order__event__organizer' 'item', 'order', 'order__event', 'order__event__organizer', 'seat'
) )
return qs return qs
@@ -960,22 +998,23 @@ class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer.save() serializer.save()
class InvoiceFilter(FilterSet): with scopes_disabled():
refers = django_filters.CharFilter(method='refers_qs') class InvoiceFilter(FilterSet):
number = django_filters.CharFilter(method='nr_qs') refers = django_filters.CharFilter(method='refers_qs')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact') number = django_filters.CharFilter(method='nr_qs')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
def refers_qs(self, queryset, name, value): def refers_qs(self, queryset, name, value):
return queryset.annotate( return queryset.annotate(
refers_nr=Concat('refers__prefix', 'refers__invoice_no') refers_nr=Concat('refers__prefix', 'refers__invoice_no')
).filter(refers_nr__iexact=value) ).filter(refers_nr__iexact=value)
def nr_qs(self, queryset, name, value): def nr_qs(self, queryset, name, value):
return queryset.filter(nr__iexact=value) return queryset.filter(nr__iexact=value)
class Meta: class Meta:
model = Invoice model = Invoice
fields = ['order', 'number', 'is_cancellation', 'refers', 'locale'] fields = ['order', 'number', 'is_cancellation', 'refers', 'locale']
class RetryException(APIException): class RetryException(APIException):

View File

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

View File

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

View File

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

View File

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

View File

@@ -13,7 +13,7 @@ class PretixBaseConfig(AppConfig):
from . import invoice # NOQA from . import invoice # NOQA
from . import notifications # NOQA from . import notifications # NOQA
from . import email # NOQA from . import email # NOQA
from .services import auth, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA from .services import auth, checkin, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
try: try:
from .celery_app import app as celery_app # NOQA from .celery_app import app as celery_app # NOQA

View File

@@ -0,0 +1,80 @@
import re
# banlist based on http://www.bannedwordlist.com/lists/swearWords.txt
banlist = [
"anal",
"anus",
"arse",
"ass",
"balls",
"bastard",
"bitch",
"biatch",
"bloody",
"blowjob",
"bollock",
"bollok",
"boner",
"boob",
"bugger",
"bum",
"butt",
"clitoris",
"cock",
"coon",
"crap",
"cunt",
"damn",
"dick",
"dildo",
"dyke",
"fag",
"feck",
"fellate",
"fellatio",
"felching",
"fuck",
"fudgepacker",
"flange",
"goddamn",
"hell",
"homo",
"jerk",
"jizz",
"knobend",
"labia",
"lmao",
"lmfao",
"muff",
"nigger",
"nigga",
"omg",
"penis",
"piss",
"poop",
"prick",
"pube",
"pussy",
"queer",
"scrotum",
"sex",
"shit",
"sh1t",
"slut",
"smegma",
"spunk",
"tit",
"tosser",
"turd",
"twat",
"vagina",
"wank",
"whore",
"wtf"
]
blacklist_regex = re.compile('(' + '|'.join(banlist) + ')')
def banned(string):
return bool(blacklist_regex.search(string.lower()))

View File

@@ -0,0 +1,13 @@
import sys
from django.conf import settings
def contextprocessor(request):
ctx = {}
if settings.DEBUG and 'runserver' not in sys.argv:
ctx['debug_warning'] = True
elif 'runserver' in sys.argv:
ctx['development_warning'] = True
return ctx

View File

@@ -1,15 +1,23 @@
import inspect
import logging import logging
from datetime import timedelta
from decimal import Decimal
from smtplib import SMTPResponseException from smtplib import SMTPResponseException
from django.conf import settings from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend from django.core.mail.backends.smtp import EmailBackend
from django.dispatch import receiver from django.dispatch import receiver
from django.template.loader import get_template from django.template.loader import get_template
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from inlinestyler.utils import inline_css from inlinestyler.utils import inline_css
from pretix.base.models import Event, Order, OrderPosition from pretix.base.i18n import LazyCurrencyNumber, LazyDate, LazyNumber
from pretix.base.signals import register_html_mail_renderers from pretix.base.models import Event
from pretix.base.settings import PERSON_NAME_SCHEMES
from pretix.base.signals import (
register_html_mail_renderers, register_mail_placeholders,
)
from pretix.base.templatetags.rich_text import markdown_compile_email from pretix.base.templatetags.rich_text import markdown_compile_email
logger = logging.getLogger('pretix.base.email') logger = logging.getLogger('pretix.base.email')
@@ -44,8 +52,8 @@ class BaseHTMLMailRenderer:
def __str__(self): def __str__(self):
return self.identifier return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None, def render(self, plain_body: str, plain_signature: str, subject: str, order=None,
position: OrderPosition=None) -> str: position=None) -> str:
""" """
This method should generate the HTML part of the email. This method should generate the HTML part of the email.
@@ -97,7 +105,7 @@ class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
def template_name(self): def template_name(self):
raise NotImplementedError() raise NotImplementedError()
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order, position: OrderPosition) -> str: def render(self, plain_body: str, plain_signature: str, subject: str, order, position) -> str:
body_md = markdown_compile_email(plain_body) body_md = markdown_compile_email(plain_body)
htmlctx = { htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME, 'site': settings.PRETIX_INSTANCE_NAME,
@@ -136,3 +144,285 @@ class ClassicMailRenderer(TemplateBasedMailRenderer):
@receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers") @receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers")
def base_renderers(sender, **kwargs): def base_renderers(sender, **kwargs):
return [ClassicMailRenderer] return [ClassicMailRenderer]
class BaseMailTextPlaceholder:
"""
This is the base class for for all email text placeholders.
"""
@property
def required_context(self):
"""
This property should return a list of all attribute names that need to be
contained in the base context so that this placeholder is available. By default,
it returns a list containing the string "event".
"""
return ["event"]
@property
def identifier(self):
"""
This should return the identifier of this placeholder in the email.
"""
raise NotImplementedError()
def render(self, context):
"""
This method is called to generate the actual text that is being
used in the email. You will be passed a context dictionary with the
base context attributes specified in ``required_context``. You are
expected to return a plain-text string.
"""
raise NotImplementedError()
def render_sample(self, event):
"""
This method is called to generate a text to be used in email previews.
This may only depend on the event.
"""
raise NotImplementedError()
class SimpleFunctionalMailTextPlaceholder(BaseMailTextPlaceholder):
def __init__(self, identifier, args, func, sample):
self._identifier = identifier
self._args = args
self._func = func
self._sample = sample
@property
def identifier(self):
return self._identifier
@property
def required_context(self):
return self._args
def render(self, context):
return self._func(**{k: context[k] for k in self._args})
def render_sample(self, event):
if callable(self._sample):
return self._sample(event)
else:
return self._sample
def get_available_placeholders(event, base_parameters):
if 'order' in base_parameters:
base_parameters.append('invoice_address')
params = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in base_parameters for rp in v.required_context):
params[v.identifier] = v
return params
def get_email_context(**kwargs):
from pretix.base.models import InvoiceAddress
event = kwargs['event']
if 'order' in kwargs:
try:
kwargs['invoice_address'] = kwargs['order'].invoice_address
except InvoiceAddress.DoesNotExist:
kwargs['invoice_address'] = InvoiceAddress()
ctx = {}
for r, val in register_mail_placeholders.send(sender=event):
if not isinstance(val, (list, tuple)):
val = [val]
for v in val:
if all(rp in kwargs for rp in v.required_context):
ctx[v.identifier] = v.render(kwargs)
return ctx
def _placeholder_payment(order, payment):
if not payment:
return None
if 'payment' in inspect.signature(payment.payment_provider.order_pending_mail_render).parameters:
return str(payment.payment_provider.order_pending_mail_render(order, payment))
else:
return str(payment.payment_provider.order_pending_mail_render(order))
@receiver(register_mail_placeholders, dispatch_uid="pretixbase_register_mail_placeholders")
def base_placeholders(sender, **kwargs):
from pretix.base.models import InvoiceAddress
from pretix.multidomain.urlreverse import build_absolute_uri
ph = [
SimpleFunctionalMailTextPlaceholder(
'event', ['event'], lambda event: event.name, lambda event: event.name
),
SimpleFunctionalMailTextPlaceholder(
'code', ['order'], lambda order: order.code, 'F8VVL'
),
SimpleFunctionalMailTextPlaceholder(
'total', ['order'], lambda order: LazyNumber(order.total), lambda event: LazyNumber(Decimal('42.23'))
),
SimpleFunctionalMailTextPlaceholder(
'currency', ['event'], lambda event: event.currency, lambda event: event.currency
),
SimpleFunctionalMailTextPlaceholder(
'total_with_currency', ['event', 'order'], lambda event, order: LazyCurrencyNumber(order.total,
event.currency),
lambda event: LazyCurrencyNumber(Decimal('42.23'), event.currency)
),
SimpleFunctionalMailTextPlaceholder(
'expire_date', ['event', 'order'], lambda event, order: LazyDate(order.expires.astimezone(event.timezone)),
lambda event: LazyDate(now() + timedelta(days=15))
# TODO: This used to be "date" in some placeholders, add a migration!
),
SimpleFunctionalMailTextPlaceholder(
'url', ['order', 'event'], lambda order, event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': order.code,
'secret': order.secret,
'hash': order.email_confirm_hash()
}
), lambda event: build_absolute_uri(
event,
'presale:event.order.open', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'hash': '98kusd8ofsj8dnkd'
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['event', 'position'], lambda event, position: build_absolute_uri(
event,
'presale:event.order.position',
kwargs={
'order': position.order.code,
'secret': position.web_secret,
'position': position.positionid
}
),
lambda event: build_absolute_uri(
event,
'presale:event.order.position', kwargs={
'order': 'F8VVL',
'secret': '6zzjnumtsx136ddy',
'position': '123'
}
),
),
SimpleFunctionalMailTextPlaceholder(
'url', ['waiting_list_entry', 'event'],
lambda waiting_list_entry, event: build_absolute_uri(
event, 'presale:event.redeem'
) + '?voucher=' + waiting_list_entry.voucher.code,
lambda event: build_absolute_uri(
event,
'presale:event.redeem',
) + '?voucher=68CYU2H6ZTP3WLK5',
),
SimpleFunctionalMailTextPlaceholder(
'invoice_name', ['invoice_address'], lambda invoice_address: invoice_address.name or '',
_('John Doe')
),
SimpleFunctionalMailTextPlaceholder(
'invoice_company', ['invoice_address'], lambda invoice_address: invoice_address.company or '',
_('Sample Corporation')
),
SimpleFunctionalMailTextPlaceholder(
'orders', ['event', 'orders'], lambda event, orders: '\n' + '\n\n'.join(
'* {} - {}'.format(
order.full_code,
build_absolute_uri(event, 'presale:event.order', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order.code,
'secret': order.secret
}),
)
for order in orders
), lambda event: '\n' + '\n\n'.join(
'* {} - {}'.format(
'{}-{}'.format(event.slug.upper(), order['code']),
build_absolute_uri(event, 'presale:event.order', kwargs={
'event': event.slug,
'organizer': event.organizer.slug,
'order': order['code'],
'secret': order['secret']
}),
)
for order in [
{'code': 'F8VVL', 'secret': '6zzjnumtsx136ddy'},
{'code': 'HIDHK', 'secret': '98kusd8ofsj8dnkd'},
{'code': 'OPKSB', 'secret': '09pjdksflosk3njd'}
]
),
),
SimpleFunctionalMailTextPlaceholder(
'hours', ['event', 'waiting_list_entry'], lambda event, waiting_list_entry:
event.settings.waiting_list_hours,
lambda event: event.settings.waiting_list_hours
),
SimpleFunctionalMailTextPlaceholder(
'product', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.item.name,
_('Sample Admission Ticket')
),
SimpleFunctionalMailTextPlaceholder(
'code', ['waiting_list_entry'], lambda waiting_list_entry: waiting_list_entry.voucher.code,
'68CYU2H6ZTP3WLK5'
),
SimpleFunctionalMailTextPlaceholder(
'comment', ['comment'], lambda comment: comment,
_('An individual text with a reason can be inserted here.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['order', 'payment'], _placeholder_payment,
_('The amount has been charged to your card.'),
),
SimpleFunctionalMailTextPlaceholder(
'payment_info', ['payment_info'], lambda payment_info: payment_info,
_('Please transfer money to this bank account: 9999-9999-9999-9999'),
),
SimpleFunctionalMailTextPlaceholder(
'attendee_name', ['position'], lambda position: position.attendee_name,
_('John Doe'),
),
SimpleFunctionalMailTextPlaceholder(
'name', ['position_or_address'],
lambda position_or_address: (
position_or_address.name
if isinstance(position_or_address, InvoiceAddress)
else position_or_address.attendee_name
),
_('John Doe'),
),
]
name_scheme = PERSON_NAME_SCHEMES[sender.settings.name_scheme]
for f, l, w in name_scheme['fields']:
if f == 'full_name':
continue
ph.append(SimpleFunctionalMailTextPlaceholder(
'attendee_name_%s' % f, ['position'], lambda position, f=f: position.attendee_name_parts.get(f, ''),
name_scheme['sample'][f]
))
ph.append(SimpleFunctionalMailTextPlaceholder(
'name_%s' % f, ['position_or_address'],
lambda position_or_address, f=f: (
position_or_address.name_parts.get(f, '')
if isinstance(position_or_address, InvoiceAddress)
else position_or_address.attendee_name_parts.get(f, '')
),
name_scheme['sample'][f]
))
for k, v in sender.meta_data.items():
ph.append(SimpleFunctionalMailTextPlaceholder(
'meta_%s' % k, ['event'], lambda event, k=k: event.meta_data[k],
v
))
return ph

View File

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

View File

@@ -129,8 +129,11 @@ class DekodiNREIExporter(BaseExporter):
'DIDt': invoice.order.datetime.isoformat().replace('Z', '+00:00'), 'DIDt': invoice.order.datetime.isoformat().replace('Z', '+00:00'),
'DT': '30' if invoice.is_cancellation else '10', 'DT': '30' if invoice.is_cancellation else '10',
'EM': invoice.order.email, 'EM': invoice.order.email,
'FamN': invoice.invoice_to_name.rsplit(' ', 1)[-1], 'FamN': invoice.invoice_to_name.rsplit(' ', 1)[-1] if invoice.invoice_to_name else '',
'FN': invoice.invoice_to_name.rsplit(' ', 1)[0] if ' ' in invoice.invoice_to_name else '', 'FN': (
invoice.invoice_to_name.rsplit(' ', 1)[0]
if invoice.invoice_to_name and ' ' in invoice.invoice_to_name else ''
),
'IDt': invoice.date.isoformat() + 'T08:00:00+01:00', 'IDt': invoice.date.isoformat() + 'T08:00:00+01:00',
'INo': invoice.full_invoice_no, 'INo': invoice.full_invoice_no,
'IsNet': invoice.reverse_charge, 'IsNet': invoice.reverse_charge,

View File

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

View File

@@ -9,7 +9,7 @@ from django.utils.formats import date_format, localize
from django.utils.translation import pgettext, ugettext as _, ugettext_lazy from django.utils.translation import pgettext, ugettext as _, ugettext_lazy
from pretix.base.models import ( from pretix.base.models import (
InvoiceAddress, InvoiceLine, Order, OrderPosition, InvoiceAddress, InvoiceLine, Order, OrderPosition, Question,
) )
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import PERSON_NAME_SCHEMES
@@ -96,7 +96,7 @@ class OrderListExporter(MultiSheetListExporter):
for k, label, w in name_scheme['fields']: for k, label, w in name_scheme['fields']:
headers.append(label) headers.append(label)
headers += [ headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), _('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
_('Date of last payment'), _('Fees'), _('Order locale') _('Date of last payment'), _('Fees'), _('Order locale')
] ]
@@ -109,6 +109,8 @@ class OrderListExporter(MultiSheetListExporter):
headers.append(_('Invoice numbers')) headers.append(_('Invoice numbers'))
headers.append(_('Sales channel')) headers.append(_('Sales channel'))
headers.append(_('Requires special attention'))
headers.append(_('Comment'))
yield headers yield headers
@@ -153,10 +155,11 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row += [ row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '', order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
@@ -178,6 +181,8 @@ class OrderListExporter(MultiSheetListExporter):
row.append(', '.join([i.number for i in order.invoices.all()])) row.append(', '.join([i.number for i in order.invoices.all()]))
row.append(order.sales_channel) row.append(order.sales_channel)
row.append(_('Yes') if order.checkin_attention else _('No'))
row.append(order.comment or "")
yield row yield row
def iterate_fees(self, form_data: dict): def iterate_fees(self, form_data: dict):
@@ -208,7 +213,7 @@ class OrderListExporter(MultiSheetListExporter):
for k, label, w in name_scheme['fields']: for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label)) headers.append(_('Invoice address name') + ': ' + str(label))
headers += [ headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), _('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
] ]
yield headers yield headers
@@ -243,10 +248,11 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
yield row yield row
def iterate_positions(self, form_data: dict): def iterate_positions(self, form_data: dict):
@@ -301,7 +307,7 @@ class OrderListExporter(MultiSheetListExporter):
for k, label, w in name_scheme['fields']: for k, label, w in name_scheme['fields']:
headers.append(_('Invoice address name') + ': ' + str(label)) headers.append(_('Invoice address name') + ': ' + str(label))
headers += [ headers += [
_('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'), _('Address'), _('ZIP code'), _('City'), _('Country'), pgettext('address', 'State'), _('VAT ID'),
] ]
headers.append(_('Sales channel')) headers.append(_('Sales channel'))
@@ -339,7 +345,12 @@ class OrderListExporter(MultiSheetListExporter):
] ]
acache = {} acache = {}
for a in op.answers.all(): for a in op.answers.all():
acache[a.question_id] = str(a) # We do not want to localize Date, Time and Datetime question answers, as those can lead
# to difficulties parsing the data (for example 2019-02-01 may become Février, 2019 01 in French).
if a.question.type in Question.UNLOCALIZED_TYPES:
acache[a.question_id] = a.answer
else:
acache[a.question_id] = str(a)
for q in questions: for q in questions:
row.append(acache.get(q.pk, '')) row.append(acache.get(q.pk, ''))
try: try:
@@ -358,10 +369,11 @@ class OrderListExporter(MultiSheetListExporter):
order.invoice_address.city, order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old, order.invoice_address.country_old,
order.invoice_address.state,
order.invoice_address.vat_id, order.invoice_address.vat_id,
] ]
except InvoiceAddress.DoesNotExist: except InvoiceAddress.DoesNotExist:
row += [''] * (7 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0)) row += [''] * (8 + (len(name_scheme['fields']) if len(name_scheme['fields']) > 1 else 0))
row.append(order.sales_channel) row.append(order.sales_channel)
yield row yield row
@@ -503,6 +515,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
_('Invoice recipient:') + ' ' + _('ZIP code'), _('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'), _('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'), _('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'), _('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'), _('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'), _('Invoice recipient:') + ' ' + _('Internal reference'),
@@ -552,6 +565,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
i.invoice_to_zipcode, i.invoice_to_zipcode,
i.invoice_to_city, i.invoice_to_city,
i.invoice_to_country, i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id, i.invoice_to_vat_id,
i.invoice_to_beneficiary, i.invoice_to_beneficiary,
i.internal_reference, i.internal_reference,
@@ -591,6 +605,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
_('Invoice recipient:') + ' ' + _('ZIP code'), _('Invoice recipient:') + ' ' + _('ZIP code'),
_('Invoice recipient:') + ' ' + _('City'), _('Invoice recipient:') + ' ' + _('City'),
_('Invoice recipient:') + ' ' + _('Country'), _('Invoice recipient:') + ' ' + _('Country'),
_('Invoice recipient:') + ' ' + pgettext('address', 'State'),
_('Invoice recipient:') + ' ' + _('VAT ID'), _('Invoice recipient:') + ' ' + _('VAT ID'),
_('Invoice recipient:') + ' ' + _('Beneficiary'), _('Invoice recipient:') + ' ' + _('Beneficiary'),
_('Invoice recipient:') + ' ' + _('Internal reference'), _('Invoice recipient:') + ' ' + _('Internal reference'),
@@ -630,6 +645,7 @@ class InvoiceDataExporter(MultiSheetListExporter):
i.invoice_to_zipcode, i.invoice_to_zipcode,
i.invoice_to_city, i.invoice_to_city,
i.invoice_to_country, i.invoice_to_country,
i.invoice_to_state,
i.invoice_to_vat_id, i.invoice_to_vat_id,
i.invoice_to_beneficiary, i.invoice_to_beneficiary,
i.internal_reference, i.internal_reference,

View File

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

View File

@@ -1,17 +1,24 @@
import copy import copy
import json
import logging import logging
from decimal import Decimal from decimal import Decimal
from urllib.error import HTTPError
import dateutil.parser import dateutil.parser
import pycountry
import pytz import pytz
import vat_moss.errors import vat_moss.errors
import vat_moss.id import vat_moss.id
from django import forms from django import forms
from django.contrib import messages from django.contrib import messages
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.db.models import QuerySet
from django.forms import Select
from django.utils.html import escape from django.utils.html import escape
from django.utils.safestring import mark_safe from django.utils.safestring import mark_safe
from django.utils.translation import get_language, ugettext_lazy as _ from django.utils.translation import (
get_language, pgettext_lazy, ugettext_lazy as _,
)
from django_countries import countries from django_countries import countries
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
@@ -20,10 +27,14 @@ from pretix.base.forms.widgets import (
TimePickerWidget, UploadedFileWidget, TimePickerWidget, UploadedFileWidget,
) )
from pretix.base.models import InvoiceAddress, Question, QuestionOption from pretix.base.models import InvoiceAddress, Question, QuestionOption
from pretix.base.models.tax import EU_COUNTRIES from pretix.base.models.tax import EU_COUNTRIES, cc_to_vat_prefix
from pretix.base.settings import PERSON_NAME_SCHEMES from pretix.base.settings import (
COUNTRIES_WITH_STATE_IN_ADDRESS, PERSON_NAME_SCHEMES,
PERSON_NAME_TITLE_GROUPS,
)
from pretix.base.templatetags.rich_text import rich_text from pretix.base.templatetags.rich_text import rich_text
from pretix.control.forms import SplitDateTimeField from pretix.control.forms import SplitDateTimeField
from pretix.helpers.escapejson import escapejson_attr
from pretix.helpers.i18n import get_format_without_seconds from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields from pretix.presale.signals import question_form_fields
@@ -32,15 +43,27 @@ logger = logging.getLogger(__name__)
class NamePartsWidget(forms.MultiWidget): class NamePartsWidget(forms.MultiWidget):
widget = forms.TextInput widget = forms.TextInput
autofill_map = {
'given_name': 'given-name',
'family_name': 'family-name',
'middle_name': 'additional-name',
'title': 'honorific-prefix',
'full_name': 'name',
'calling_name': 'nickname',
}
def __init__(self, scheme: dict, field: forms.Field, attrs=None): def __init__(self, scheme: dict, field: forms.Field, attrs=None, titles: list=None):
widgets = [] widgets = []
self.scheme = scheme self.scheme = scheme
self.field = field self.field = field
self.titles = titles
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
a = copy.copy(attrs) or {} a = copy.copy(attrs) or {}
a['data-fname'] = fname a['data-fname'] = fname
widgets.append(self.widget(attrs=a)) if fname == 'title' and self.titles:
widgets.append(Select(attrs=a, choices=[('', '')] + [(d, d) for d in self.titles[1]]))
else:
widgets.append(self.widget(attrs=a))
super().__init__(widgets, attrs) super().__init__(widgets, attrs)
def decompress(self, value): def decompress(self, value):
@@ -74,6 +97,7 @@ class NamePartsWidget(forms.MultiWidget):
title=self.scheme['fields'][i][1], title=self.scheme['fields'][i][1],
placeholder=self.scheme['fields'][i][1], placeholder=self.scheme['fields'][i][1],
) )
final_attrs['autocomplete'] = (self.attrs.get('autocomplete', '') + ' ' + self.autofill_map.get(self.scheme['fields'][i][0], 'off')).strip()
final_attrs['data-size'] = self.scheme['fields'][i][2] final_attrs['data-size'] = self.scheme['fields'][i][2]
output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer)) output.append(widget.render(name + '_%s' % i, widget_value, final_attrs, renderer=renderer))
return mark_safe(self.format_output(output)) return mark_safe(self.format_output(output))
@@ -99,19 +123,34 @@ class NamePartsFormField(forms.MultiValueField):
'max_length': kwargs.pop('max_length', None), 'max_length': kwargs.pop('max_length', None),
} }
self.scheme_name = kwargs.pop('scheme') self.scheme_name = kwargs.pop('scheme')
self.titles = kwargs.pop('titles')
self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name) self.scheme = PERSON_NAME_SCHEMES.get(self.scheme_name)
if self.titles:
self.scheme_titles = PERSON_NAME_TITLE_GROUPS.get(self.titles)
else:
self.scheme_titles = None
self.one_required = kwargs.get('required', True) self.one_required = kwargs.get('required', True)
require_all_fields = kwargs.pop('require_all_fields', False) require_all_fields = kwargs.pop('require_all_fields', False)
kwargs['required'] = False kwargs['required'] = False
kwargs['widget'] = (kwargs.get('widget') or self.widget)( kwargs['widget'] = (kwargs.get('widget') or self.widget)(
scheme=self.scheme, field=self, **kwargs.pop('widget_kwargs', {}) scheme=self.scheme, titles=self.scheme_titles, field=self, **kwargs.pop('widget_kwargs', {})
) )
defaults.update(**kwargs) defaults.update(**kwargs)
for fname, label, size in self.scheme['fields']: for fname, label, size in self.scheme['fields']:
defaults['label'] = label defaults['label'] = label
field = forms.CharField(**defaults) if fname == 'title' and self.scheme_titles:
field.part_name = fname d = dict(defaults)
fields.append(field) d.pop('max_length', None)
field = forms.ChoiceField(
**d,
choices=[('', '')] + [(d, d) for d in self.scheme_titles[1]]
)
field.part_name = fname
fields.append(field)
else:
field = forms.CharField(**defaults)
field.part_name = fname
fields.append(field)
super().__init__( super().__init__(
fields=fields, require_all_fields=False, *args, **kwargs fields=fields, require_all_fields=False, *args, **kwargs
) )
@@ -156,6 +195,7 @@ class BaseQuestionsForm(forms.Form):
max_length=255, max_length=255,
required=event.settings.attendee_names_required, required=event.settings.attendee_names_required,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Attendee name'), label=_('Attendee name'),
initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts), initial=(cartpos.attendee_name_parts if cartpos else orderpos.attendee_name_parts),
) )
@@ -163,7 +203,12 @@ class BaseQuestionsForm(forms.Form):
self.fields['attendee_email'] = forms.EmailField( self.fields['attendee_email'] = forms.EmailField(
required=event.settings.attendee_emails_required, required=event.settings.attendee_emails_required,
label=_('Attendee email'), label=_('Attendee email'),
initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email) initial=(cartpos.attendee_email if cartpos else orderpos.attendee_email),
widget=forms.EmailInput(
attrs={
'autocomplete': 'email'
}
)
) )
for q in questions: for q in questions:
@@ -277,7 +322,7 @@ class BaseQuestionsForm(forms.Form):
if q.dependency_question_id: if q.dependency_question_id:
field.widget.attrs['data-question-dependency'] = q.dependency_question_id field.widget.attrs['data-question-dependency'] = q.dependency_question_id
field.widget.attrs['data-question-dependency-value'] = q.dependency_value field.widget.attrs['data-question-dependency-values'] = escapejson_attr(json.dumps(q.dependency_values))
if q.type != 'M': if q.type != 'M':
field.widget.attrs['required'] = q.required and not self.all_optional field.widget.attrs['required'] = q.required and not self.all_optional
field._required = q.required and not self.all_optional field._required = q.required and not self.all_optional
@@ -293,31 +338,33 @@ class BaseQuestionsForm(forms.Form):
self.fields[key] = value self.fields[key] = value
value.initial = data.get('question_form_data', {}).get(key) value.initial = data.get('question_form_data', {}).get(key)
for k, v in self.fields.items():
if v.widget.attrs.get('autocomplete') or k == 'attendee_name_parts':
v.widget.attrs['autocomplete'] = 'section-{} '.format(self.prefix) + v.widget.attrs.get('autocomplete', '')
def clean(self): def clean(self):
d = super().clean() d = super().clean()
question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)} question_cache = {f.question.pk: f.question for f in self.fields.values() if getattr(f, 'question', None)}
def question_is_visible(parentid, qval): def question_is_visible(parentid, qvals):
parentq = question_cache[parentid] parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value): if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False return False
if 'question_%d' % parentid not in d: if 'question_%d' % parentid not in d:
return False return False
dval = d.get('question_%d' % parentid) dval = d.get('question_%d' % parentid)
if qval == 'True': return (
return dval ('True' in qvals and dval)
elif qval == 'False': or ('False' in qvals and not dval)
return not dval or (isinstance(dval, QuestionOption) and dval.identifier in qvals)
elif isinstance(dval, QuestionOption): or (isinstance(dval, (list, QuerySet)) and any(qval in [o.identifier for o in dval] for qval in qvals))
return dval.identifier == qval )
else:
return qval in [o.identifier for o in dval]
def question_is_required(q): def question_is_required(q):
return ( return (
q.required and q.required and
(not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value)) (not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values))
) )
if not self.all_optional: if not self.all_optional:
@@ -333,13 +380,29 @@ class BaseInvoiceAddressForm(forms.ModelForm):
class Meta: class Meta:
model = InvoiceAddress model = InvoiceAddress
fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'vat_id', fields = ('is_business', 'company', 'name_parts', 'street', 'zipcode', 'city', 'country', 'state',
'internal_reference', 'beneficiary') 'vat_id', 'internal_reference', 'beneficiary')
widgets = { widgets = {
'is_business': BusinessBooleanRadio, 'is_business': BusinessBooleanRadio,
'street': forms.Textarea(attrs={'rows': 2, 'placeholder': _('Street and Number')}), 'street': forms.Textarea(attrs={
'rows': 2,
'placeholder': _('Street and Number'),
'autocomplete': 'street-address'
}),
'beneficiary': forms.Textarea(attrs={'rows': 3}), 'beneficiary': forms.Textarea(attrs={'rows': 3}),
'company': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'country': forms.Select(attrs={
'autocomplete': 'country',
}),
'zipcode': forms.TextInput(attrs={
'autocomplete': 'postal-code',
}),
'city': forms.TextInput(attrs={
'autocomplete': 'address-level2',
}),
'company': forms.TextInput(attrs={
'data-display-dependency': '#id_is_business_1',
'autocomplete': 'organization',
}),
'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}), 'vat_id': forms.TextInput(attrs={'data-display-dependency': '#id_is_business_1'}),
'internal_reference': forms.TextInput, 'internal_reference': forms.TextInput,
} }
@@ -377,6 +440,33 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not event.settings.invoice_address_vatid: if not event.settings.invoice_address_vatid:
del self.fields['vat_id'] del self.fields['vat_id']
c = [('', pgettext_lazy('address', 'Select state'))]
fprefix = self.prefix + '-' if self.prefix else ''
cc = None
if fprefix + 'country' in self.data:
cc = str(self.data[fprefix + 'country'])
elif 'country' in self.initial:
cc = str(self.initial['country'])
elif self.instance and self.instance.country:
cc = str(self.instance.country)
if cc and cc in COUNTRIES_WITH_STATE_IN_ADDRESS:
types, form = COUNTRIES_WITH_STATE_IN_ADDRESS[cc]
statelist = [s for s in pycountry.subdivisions.get(country_code=cc) if s.type in types]
c += sorted([(s.code[3:], s.name) for s in statelist], key=lambda s: s[1])
elif fprefix + 'state' in self.data:
self.data = self.data.copy()
del self.data[fprefix + 'state']
self.fields['state'] = forms.ChoiceField(
label=pgettext_lazy('address', 'State'),
required=False,
choices=c,
widget=forms.Select(attrs={
'autocomplete': 'address-level1',
}),
)
self.fields['state'].widget.is_required = True
if not event.settings.invoice_address_required or self.all_optional: if not event.settings.invoice_address_required or self.all_optional:
for k, f in self.fields.items(): for k, f in self.fields.items():
f.required = False f.required = False
@@ -398,6 +488,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
max_length=255, max_length=255,
required=event.settings.invoice_name_required and not self.all_optional, required=event.settings.invoice_name_required and not self.all_optional,
scheme=event.settings.name_scheme, scheme=event.settings.name_scheme,
titles=event.settings.name_scheme_titles,
label=_('Name'), label=_('Name'),
initial=(self.instance.name_parts if self.instance else self.instance.name_parts), initial=(self.instance.name_parts if self.instance else self.instance.name_parts),
) )
@@ -409,6 +500,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if not event.settings.invoice_address_beneficiary: if not event.settings.invoice_address_beneficiary:
del self.fields['beneficiary'] del self.fields['beneficiary']
for k, v in self.fields.items():
if v.widget.attrs.get('autocomplete') or k == 'name_parts':
v.widget.attrs['autocomplete'] = 'section-invoice billing ' + v.widget.attrs.get('autocomplete', '')
def clean(self): def clean(self):
data = self.cleaned_data data = self.cleaned_data
if not data.get('is_business'): if not data.get('is_business'):
@@ -422,6 +517,10 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if 'vat_id' in self.changed_data or not data.get('vat_id'): if 'vat_id' in self.changed_data or not data.get('vat_id'):
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
if data.get('city') and data.get('country') and str(data['country']) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if not data.get('state'):
self.add_error('state', _('This field is required.'))
self.instance.name_parts = data.get('name_parts') self.instance.name_parts = data.get('name_parts')
if all( if all(
@@ -433,7 +532,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data: if self.validate_vat_id and self.instance.vat_id_validated and 'vat_id' not in self.changed_data:
pass pass
elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'): elif self.validate_vat_id and data.get('is_business') and data.get('country') in EU_COUNTRIES and data.get('vat_id'):
if data.get('vat_id')[:2] != str(data.get('country')): if data.get('vat_id')[:2] != cc_to_vat_prefix(str(data.get('country'))):
raise ValidationError(_('Your VAT ID does not match the selected country.')) raise ValidationError(_('Your VAT ID does not match the selected country.'))
try: try:
result = vat_moss.id.validate(data.get('vat_id')) result = vat_moss.id.validate(data.get('vat_id'))
@@ -451,7 +550,7 @@ class BaseInvoiceAddressForm(forms.ModelForm):
'your country is currently not available. We will therefore ' 'your country is currently not available. We will therefore '
'need to charge VAT on your invoice. You can get the tax amount ' 'need to charge VAT on your invoice. You can get the tax amount '
'back via the VAT reimbursement process.')) 'back via the VAT reimbursement process.'))
except vat_moss.errors.WebServiceError: except (vat_moss.errors.WebServiceError, HTTPError):
logger.exception('VAT ID checking failed for country {}'.format(data.get('country'))) logger.exception('VAT ID checking failed for country {}'.format(data.get('country')))
self.instance.vat_id_validated = False self.instance.vat_id_validated = False
if self.request and self.vat_warning: if self.request and self.vat_warning:
@@ -464,9 +563,8 @@ class BaseInvoiceAddressForm(forms.ModelForm):
class BaseInvoiceNameForm(BaseInvoiceAddressForm): class BaseInvoiceNameForm(BaseInvoiceAddressForm):
def __init__(self, *args, **kwargs): def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs) super().__init__(*args, **kwargs)
for f in list(self.fields.keys()): for f in list(self.fields.keys()):
if f != 'name': if f != 'name_parts':
del self.fields[f] del self.fields[f]

View File

@@ -115,5 +115,5 @@ class User2FADeviceAddForm(forms.Form):
name = forms.CharField(label=_('Device name'), max_length=64) name = forms.CharField(label=_('Device name'), max_length=64)
devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=( devicetype = forms.ChoiceField(label=_('Device type'), widget=forms.RadioSelect, choices=(
('totp', _('Smartphone with the Authenticator application')), ('totp', _('Smartphone with the Authenticator application')),
('u2f', _('U2F-compatible hardware token (e.g. Yubikey)')), ('webauthn', _('WebAuthn-compatible hardware token (e.g. Yubikey)')),
)) ))

View File

@@ -26,7 +26,7 @@ class PlaceholderValidator(BaseValidator):
if value.count('{') != value.count('}'): if value.count('{') != value.count('}'):
raise ValidationError( raise ValidationError(
_('Invalid placeholder syntax: You used a different number of "{" than of "}".'), _('Invalid placeholder syntax: You used a different number of "{" than of "}".'),
code='invalid', code='invalid_placeholder_syntax',
) )
data_placeholders = list(re.findall(r'({[^}]*})', value, re.X)) data_placeholders = list(re.findall(r'({[^}]*})', value, re.X))
@@ -37,7 +37,7 @@ class PlaceholderValidator(BaseValidator):
if invalid_placeholders: if invalid_placeholders:
raise ValidationError( raise ValidationError(
_('Invalid placeholder(s): %(value)s'), _('Invalid placeholder(s): %(value)s'),
code='invalid', code='invalid_placeholders',
params={'value': ", ".join(invalid_placeholders,)}) params={'value': ", ".join(invalid_placeholders,)})
def clean(self, x): def clean(self, x):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,27 @@
# Generated by Django 2.2.1 on 2019-08-08 12:53
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0131_auto_20190729_1422'),
]
operations = [
migrations.AddField(
model_name='invoice',
name='invoice_to_state',
field=models.CharField(max_length=190, null=True),
),
migrations.AddField(
model_name='invoiceaddress',
name='state',
field=models.CharField(default='', max_length=255),
preserve_default=False,
),
]

View File

@@ -0,0 +1,21 @@
# Generated by Django 2.2.4 on 2019-08-30 15:13
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0132_auto_20190808_1253'),
]
operations = [
migrations.AddField(
model_name='question',
name='print_on_invoice',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,35 @@
# Generated by Django 2.2.4 on 2019-09-09 10:42
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0133_auto_20190830_1513'),
]
operations = [
migrations.CreateModel(
name='WebAuthnDevice',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False)),
('name', models.CharField(max_length=64)),
('confirmed', models.BooleanField(default=True)),
('credential_id', models.CharField(max_length=255, null=True)),
('rp_id', models.CharField(max_length=255, null=True)),
('icon_url', models.CharField(max_length=255, null=True)),
('ukey', models.TextField(null=True)),
('pub_key', models.TextField(null=True)),
('sign_count', models.IntegerField(default=0)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
],
options={
'abstract': False,
},
),
]

View File

@@ -0,0 +1,27 @@
# Generated by Django 2.2.4 on 2019-10-07 08:03
from django.core.cache import cache
from django.db import migrations
def mail_migrator(app, schema_editor):
Event_SettingsStore = app.get_model('pretixbase', 'Event_SettingsStore')
for ss in Event_SettingsStore.objects.filter(
key__in=['mail_text_order_approved', 'mail_text_order_placed', 'mail_text_order_placed_require_approval']
):
chgd = ss.value.replace("{date}", "{expire_date}")
if chgd != ss.value:
ss.value = chgd
ss.save()
cache.delete('hierarkey_{}_{}'.format('event', ss.object_id))
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0134_auto_20190909_1042'),
]
operations = [
migrations.RunPython(mail_migrator, migrations.RunPython.noop)
]

View File

@@ -0,0 +1,25 @@
# Generated by Django 2.2 on 2019-09-18 17:42
from django.db import migrations, models
import pretix.base.models.fields
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0135_auto_20191007_0803'),
]
operations = [
migrations.AddField(
model_name='checkin',
name='auto_checked_in',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='checkinlist',
name='auto_checkin_sales_channels',
field=pretix.base.models.fields.MultiStringField(default=[]),
)
]

View File

@@ -1,5 +1,5 @@
from ..settings import GlobalSettingsObject_SettingsStore from ..settings import GlobalSettingsObject_SettingsStore
from .auth import U2FDevice, User from .auth import U2FDevice, User, WebAuthnDevice
from .base import CachedFile, LoggedModel, cachedfile_name from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin, CheckinList from .checkin import Checkin, CheckinList
from .devices import Device from .devices import Device
@@ -24,6 +24,7 @@ from .orders import (
from .organizer import ( from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite, Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,
) )
from .seating import Seat, SeatCategoryMapping, SeatingPlan
from .tax import TaxRule from .tax import TaxRule
from .vouchers import Voucher from .vouchers import Voucher
from .waitinglist import WaitingListEntry from .waitinglist import WaitingListEntry

View File

@@ -1,5 +1,9 @@
import binascii
import json
from datetime import timedelta from datetime import timedelta
from urllib.parse import urlparse
import webauthn
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import ( from django.contrib.auth.models import (
AbstractBaseUser, BaseUserManager, PermissionsMixin, AbstractBaseUser, BaseUserManager, PermissionsMixin,
@@ -12,6 +16,10 @@ from django.utils.crypto import get_random_string
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_otp.models import Device from django_otp.models import Device
from django_scopes import scopes_disabled
from u2flib_server.utils import (
pub_key_from_der, websafe_decode, websafe_encode,
)
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.helpers.urls import build_absolute_uri from pretix.helpers.urls import build_absolute_uri
@@ -175,6 +183,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
'url': build_absolute_uri('control:user.settings') 'url': build_absolute_uri('control:user.settings')
}, },
event=None, event=None,
user=self,
locale=self.locale locale=self.locale
) )
except SendMailException: except SendMailException:
@@ -190,9 +199,13 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
'url': (build_absolute_uri('control:auth.forgot.recover') 'url': (build_absolute_uri('control:auth.forgot.recover')
+ '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self))) + '?id=%d&token=%s' % (self.id, default_token_generator.make_token(self)))
}, },
None, locale=self.locale None, locale=self.locale, user=self
) )
@property
def top_logentries(self):
return self.all_logentries
@property @property
def all_logentries(self): def all_logentries(self):
from pretix.base.models import LogEntry from pretix.base.models import LogEntry
@@ -283,6 +296,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
return True return True
return False return False
@scopes_disabled()
def get_events_with_any_permission(self, request=None): def get_events_with_any_permission(self, request=None):
""" """
Returns a queryset of events the user has any permissions to. Returns a queryset of events the user has any permissions to.
@@ -300,6 +314,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
| Q(id__in=self.teams.values_list('limit_events__id', flat=True)) | Q(id__in=self.teams.values_list('limit_events__id', flat=True))
) )
@scopes_disabled()
def get_events_with_permission(self, permission, request=None): def get_events_with_permission(self, permission, request=None):
""" """
Returns a queryset of events the user has a specific permissions to. Returns a queryset of events the user has a specific permissions to.
@@ -372,3 +387,49 @@ class StaffSessionAuditLog(models.Model):
class U2FDevice(Device): class U2FDevice(Device):
json_data = models.TextField() json_data = models.TextField()
@property
def webauthnuser(self):
d = json.loads(self.json_data)
# We manually need to convert the pubkey from DER format (used in our
# former U2F implementation) to the format required by webauthn. This
# is based on the following example:
# https://www.w3.org/TR/webauthn/#sctn-encoded-credPubKey-examples
pub_key = pub_key_from_der(websafe_decode(d['publicKey'].replace('+', '-').replace('/', '_')))
pub_key = binascii.unhexlify(
'A5010203262001215820{:064x}225820{:064x}'.format(
pub_key.public_numbers().x, pub_key.public_numbers().y
)
)
return webauthn.WebAuthnUser(
d['keyHandle'],
self.user.email,
str(self.user),
settings.SITE_URL,
d['keyHandle'],
websafe_encode(pub_key),
1,
urlparse(settings.SITE_URL).netloc
)
class WebAuthnDevice(Device):
credential_id = models.CharField(max_length=255, null=True, blank=True)
rp_id = models.CharField(max_length=255, null=True, blank=True)
icon_url = models.CharField(max_length=255, null=True, blank=True)
ukey = models.TextField(null=True)
pub_key = models.TextField(null=True)
sign_count = models.IntegerField(default=0)
@property
def webauthnuser(self):
return webauthn.WebAuthnUser(
self.ukey,
self.user.email,
str(self.user),
settings.SITE_URL,
self.credential_id,
self.pub_key,
self.sign_count,
urlparse(settings.SITE_URL).netloc
)

View File

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

View File

@@ -1,10 +1,11 @@
from django.db import models from django.db import models
from django.db.models import Case, Count, F, OuterRef, Q, Subquery, When from django.db.models import Exists, OuterRef
from django.db.models.functions import Coalesce
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.models import LoggedModel from pretix.base.models import LoggedModel
from pretix.base.models.fields import MultiStringField
class CheckinList(LoggedModel): class CheckinList(LoggedModel):
@@ -17,140 +18,69 @@ class CheckinList(LoggedModel):
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'), include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
default=False, default=False,
help_text=_('With this option, people will be able to check in even if the ' help_text=_('With this option, people will be able to check in even if the '
'order have not been paid. This only works with pretixdesk ' 'order have not been paid.'))
'0.3.0 or newer or pretixdroid 1.9 or newer.'))
auto_checkin_sales_channels = MultiStringField(
default=[],
blank=True,
verbose_name=_('Sales channels to automatically check in'),
help_text=_('All items on this check-in list will be automatically marked as checked-in when purchased through '
'any of the selected sales channels. This option can be useful when tickets sold at the box office '
'are not checked again before entry and should be considered validated directly upon purchase.')
)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
ordering = ('subevent__date_from', 'name') ordering = ('subevent__date_from', 'name')
@property
def positions(self):
from . import OrderPosition, Order
qs = OrderPosition.objects.filter(
order__event=self.event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING] if self.include_pending else [Order.STATUS_PAID],
subevent=self.subevent
)
if not self.all_products:
qs = qs.filter(item__in=self.limit_products.values_list('id', flat=True))
return qs
@property
def checkin_count(self):
return self.event.cache.get_or_set(
'checkin_list_{}_checkin_count'.format(self.pk),
lambda: self.positions.annotate(
checkedin=Exists(Checkin.objects.filter(list_id=self.pk, position=OuterRef('pk')))
).filter(
checkedin=True
).count(),
60
)
@property
def percent(self):
pc = self.position_count
return round(self.checkin_count * 100 / pc) if pc else 0
@property
def position_count(self):
return self.event.cache.get_or_set(
'checkin_list_{}_position_count'.format(self.pk),
lambda: self.positions.count(),
60
)
def touch(self):
self.event.cache.delete('checkin_list_{}_position_count'.format(self.pk))
self.event.cache.delete('checkin_list_{}_checkin_count'.format(self.pk))
@staticmethod @staticmethod
def annotate_with_numbers(qs, event): def annotate_with_numbers(qs, event):
""" # This is only kept for backwards-compatibility reasons. This method used to precompute .position_count
Modifies a queryset of checkin lists by annotating it with the number of order positions and # and .checkin_count through a huge subquery chain, but was dropped for performance reasons.
checkins associated with it. return qs
"""
# Import here to prevent circular import
from . import Order, OrderPosition, Item
# This is the mother of all subqueries. Sorry. I try to explain it, at least?
# First, we prepare a subquery that for every check-in that belongs to a paid-order
# position and to the list in question. Then, we check that it also belongs to the
# correct subevent (just to be sure) and aggregate over lists (so, over everything,
# since we filtered by lists).
cqs_paid = Checkin.objects.filter(
position__order__event=event,
position__order__status=Order.STATUS_PAID,
list=OuterRef('pk')
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(position__subevent=OuterRef('subevent'))
| (Q(position__subevent__isnull=True))
).order_by().values('list').annotate(
c=Count('*')
).values('c')
cqs_paid_and_pending = Checkin.objects.filter(
position__order__event=event,
position__order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
list=OuterRef('pk')
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(position__subevent=OuterRef('subevent'))
| (Q(position__subevent__isnull=True))
).order_by().values('list').annotate(
c=Count('*')
).values('c')
# Now for the hard part: getting all order positions that contribute to this list. This
# requires us to use TWO subqueries. The first one, pqs_all, will only be used for check-in
# lists that contain all the products of the event. This is the simpler one, it basically
# looks like the check-in counter above.
pqs_all_paid = OrderPosition.objects.filter(
order__event=event,
order__status=Order.STATUS_PAID,
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
pqs_all_paid_and_pending = OrderPosition.objects.filter(
order__event=event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING]
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
# Now we need a subquery for the case of checkin lists that are limited to certain
# products. We cannot use OuterRef("limit_products") since that would do a cross-product
# with the products table and we'd get duplicate rows in the output with different annotations
# on them, which isn't useful at all. Therefore, we need to add a second layer of subqueries
# to retrieve all of those items and then check if the item_id is IN this subquery result.
pqs_limited_paid = OrderPosition.objects.filter(
order__event=event,
order__status=Order.STATUS_PAID,
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
pqs_limited_paid_and_pending = OrderPosition.objects.filter(
order__event=event,
order__status__in=[Order.STATUS_PAID, Order.STATUS_PENDING],
item_id__in=Subquery(Item.objects.filter(checkinlist__pk=OuterRef(OuterRef('pk'))).values('pk'))
).filter(
# This assumes that in an event with subevents, *all* positions have subevents
# and *all* checkin lists have a subevent assigned
Q(subevent=OuterRef('subevent'))
| (Q(subevent__isnull=True))
).order_by().values('order__event').annotate(
c=Count('*')
).values('c')
# Finally, we put all of this together. We force empty subquery aggregates to 0 by using Coalesce()
# and decide which subquery to use for this row. In the end, we compute an integer percentage in case
# we want to display a progress bar.
return qs.annotate(
checkin_count=Coalesce(
Case(
When(include_pending=True, then=Subquery(cqs_paid_and_pending, output_field=models.IntegerField())),
default=Subquery(cqs_paid, output_field=models.IntegerField()),
output_field=models.IntegerField()
),
0
),
position_count=Coalesce(
Case(
When(all_products=True, include_pending=False,
then=Subquery(pqs_all_paid, output_field=models.IntegerField())),
When(all_products=True, include_pending=True,
then=Subquery(pqs_all_paid_and_pending, output_field=models.IntegerField())),
When(all_products=False, include_pending=False,
then=Subquery(pqs_limited_paid, output_field=models.IntegerField())),
default=Subquery(pqs_limited_paid_and_pending, output_field=models.IntegerField()),
output_field=models.IntegerField()
),
0
)
).annotate(
percent=Case(
When(position_count__gt=0, then=F('checkin_count') * 100 / F('position_count')),
default=0,
output_field=models.IntegerField()
)
)
def __str__(self): def __str__(self):
return self.name return self.name
@@ -166,6 +96,9 @@ class Checkin(models.Model):
list = models.ForeignKey( list = models.ForeignKey(
'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT, 'pretixbase.CheckinList', related_name='checkins', on_delete=models.PROTECT,
) )
auto_checked_in = models.BooleanField(default=False)
objects = ScopedManager(organizer='position__order__event__organizer')
class Meta: class Meta:
unique_together = (('list', 'position'),) unique_together = (('list', 'position'),)
@@ -177,8 +110,11 @@ class Checkin(models.Model):
def save(self, **kwargs): def save(self, **kwargs):
self.position.order.touch() self.position.order.touch()
self.list.event.cache.delete('checkin_count')
self.list.touch()
super().save(**kwargs) super().save(**kwargs)
def delete(self, **kwargs): def delete(self, **kwargs):
self.position.order.touch() self.position.order.touch()
super().delete(**kwargs) super().delete(**kwargs)
self.list.touch()

View File

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

View File

@@ -17,6 +17,7 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _ from django.utils.translation import ugettext_lazy as _
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.fields import I18nCharField, I18nTextField from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.models.base import LoggedModel from pretix.base.models.base import LoggedModel
@@ -64,7 +65,7 @@ class EventMixin:
"SHORT_DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" "SHORT_DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT"
) )
def get_date_from_display(self, tz=None, show_times=True) -> str: def get_date_from_display(self, tz=None, show_times=True, short=False) -> str:
""" """
Returns a formatted string containing the start date of the event with respect Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting. to the current locale and to the ``show_times`` setting.
@@ -72,7 +73,7 @@ class EventMixin:
tz = tz or self.timezone tz = tz or self.timezone
return _date( return _date(
self.date_from.astimezone(tz), self.date_from.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT" ("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times and show_times else "DATE_FORMAT")
) )
def get_time_from_display(self, tz=None) -> str: def get_time_from_display(self, tz=None) -> str:
@@ -85,7 +86,7 @@ class EventMixin:
self.date_from.astimezone(tz), "TIME_FORMAT" self.date_from.astimezone(tz), "TIME_FORMAT"
) )
def get_date_to_display(self, tz=None) -> str: def get_date_to_display(self, tz=None, short=False) -> str:
""" """
Returns a formatted string containing the start date of the event with respect Returns a formatted string containing the start date of the event with respect
to the current locale and to the ``show_times`` setting. Returns an empty string to the current locale and to the ``show_times`` setting. Returns an empty string
@@ -96,17 +97,17 @@ class EventMixin:
return "" return ""
return _date( return _date(
self.date_to.astimezone(tz), self.date_to.astimezone(tz),
"DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT" ("SHORT_" if short else "") + ("DATETIME_FORMAT" if self.settings.show_times else "DATE_FORMAT")
) )
def get_date_range_display(self, tz=None) -> str: def get_date_range_display(self, tz=None, force_show_end=False) -> str:
""" """
Returns a formatted string containing the start date and the end date Returns a formatted string containing the start date and the end date
of the event with respect to the current locale and to the ``show_times`` and of the event with respect to the current locale and to the ``show_times`` and
``show_date_to`` settings. ``show_date_to`` settings.
""" """
tz = tz or self.timezone tz = tz or self.timezone
if not self.settings.show_date_to or not self.date_to: if (not self.settings.show_date_to and not force_show_end) or not self.date_to:
return _date(self.date_from.astimezone(tz), "DATE_FORMAT") return _date(self.date_from.astimezone(tz), "DATE_FORMAT")
return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz)) return daterange(self.date_from.astimezone(tz), self.date_to.astimezone(tz))
@@ -335,6 +336,10 @@ class Event(EventMixin, LoggedModel):
verbose_name=_('Event series'), verbose_name=_('Event series'),
default=False default=False
) )
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='events')
objects = ScopedManager(organizer='organizer')
class Meta: class Meta:
verbose_name = _("Event") verbose_name = _("Event")
@@ -345,6 +350,26 @@ class Event(EventMixin, LoggedModel):
def __str__(self): def __str__(self):
return str(self.name) return str(self.name)
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@property @property
def presale_has_ended(self): def presale_has_ended(self):
if self.has_subevents: if self.has_subevents:
@@ -491,14 +516,21 @@ class Event(EventMixin, LoggedModel):
for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'): for q in Quota.objects.filter(event=other, subevent__isnull=True).prefetch_related('items', 'variations'):
items = list(q.items.all()) items = list(q.items.all())
vars = list(q.variations.all()) vars = list(q.variations.all())
oldid = q.pk
q.pk = None q.pk = None
q.event = self q.event = self
q.cached_availability_state = None
q.cached_availability_number = None
q.cached_availability_paid_orders = None
q.cached_availability_time = None
q.closed = False
q.save() q.save()
for i in items: for i in items:
if i.pk in item_map: if i.pk in item_map:
q.items.add(item_map[i.pk]) q.items.add(item_map[i.pk])
for v in vars: for v in vars:
q.variations.add(variation_map[v.pk]) q.variations.add(variation_map[v.pk])
self.items.filter(hidden_if_available_id=oldid).update(hidden_if_available=q)
question_map = {} question_map = {}
for q in Question.objects.filter(event=other).prefetch_related('items', 'options'): for q in Question.objects.filter(event=other).prefetch_related('items', 'options'):
@@ -528,6 +560,24 @@ class Event(EventMixin, LoggedModel):
for i in items: for i in items:
cl.limit_products.add(item_map[i.pk]) cl.limit_products.add(item_map[i.pk])
if other.seating_plan:
if other.seating_plan.organizer_id == self.organizer_id:
self.seating_plan = other.seating_plan
else:
self.organizer.seating_plans.create(name=other.seating_plan.name, layout=other.seating_plan.layout)
self.save()
for m in other.seat_category_mappings.filter(subevent__isnull=True):
m.pk = None
m.event = self
m.product = item_map[m.product_id]
m.save()
for s in other.seats.filter(subevent__isnull=True):
s.pk = None
s.event = self
s.save()
for s in other.settings._objects.all(): for s in other.settings._objects.all():
s.object = self s.object = self
s.pk = None s.pk = None
@@ -558,22 +608,24 @@ class Event(EventMixin, LoggedModel):
question_map=question_map question_map=question_map
) )
def get_payment_providers(self) -> dict: def get_payment_providers(self, cached=False) -> dict:
""" """
Returns a dictionary of initialized payment providers mapped by their identifiers. Returns a dictionary of initialized payment providers mapped by their identifiers.
""" """
from ..signals import register_payment_providers from ..signals import register_payment_providers
responses = register_payment_providers.send(self) if not cached or not hasattr(self, '_cached_payment_providers'):
providers = {} responses = register_payment_providers.send(self)
for receiver, response in responses: providers = {}
if not isinstance(response, list): for receiver, response in responses:
response = [response] if not isinstance(response, list):
for p in response: response = [response]
pp = p(self) for p in response:
providers[pp.identifier] = pp pp = p(self)
providers[pp.identifier] = pp
return OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name))) self._cached_payment_providers = OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
return self._cached_payment_providers
def get_html_mail_renderer(self): def get_html_mail_renderer(self):
""" """
@@ -667,8 +719,12 @@ class Event(EventMixin, LoggedModel):
@property @property
def meta_data(self): def meta_data(self):
data = {p.name: p.default for p in self.organizer.meta_properties.all()} data = {p.name: p.default for p in self.organizer.meta_properties.all()}
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()}) if hasattr(self, 'meta_values_cached'):
return data data.update({v.property.name: v.value for v in self.meta_values_cached})
else:
data.update({v.property.name: v.value for v in self.meta_values.select_related('property').all()})
return OrderedDict((k, v) for k, v in sorted(data.items(), key=lambda k: k[0]))
@property @property
def has_payment_provider(self): def has_payment_provider(self):
@@ -770,18 +826,24 @@ class Event(EventMixin, LoggedModel):
def enable_plugin(self, module, allow_restricted=False): def enable_plugin(self, module, allow_restricted=False):
plugins_active = self.get_plugins() plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css
if module not in plugins_active: if module not in plugins_active:
plugins_active.append(module) plugins_active.append(module)
self.set_active_plugins(plugins_active, allow_restricted=allow_restricted) self.set_active_plugins(plugins_active, allow_restricted=allow_restricted)
regenerate_css.apply_async(args=(self.pk,))
def disable_plugin(self, module): def disable_plugin(self, module):
plugins_active = self.get_plugins() plugins_active = self.get_plugins()
from pretix.presale.style import regenerate_css
if module in plugins_active: if module in plugins_active:
plugins_active.remove(module) plugins_active.remove(module)
self.set_active_plugins(plugins_active) self.set_active_plugins(plugins_active)
regenerate_css.apply_async(args=(self.pk,))
@staticmethod @staticmethod
def clean_has_subevents(event, has_subevents): def clean_has_subevents(event, has_subevents):
if event is not None and event.has_subevents is not None: if event is not None and event.has_subevents is not None:
@@ -871,10 +933,14 @@ class SubEvent(EventMixin, LoggedModel):
null=True, blank=True, null=True, blank=True,
verbose_name=_("Frontpage text") verbose_name=_("Frontpage text")
) )
seating_plan = models.ForeignKey('SeatingPlan', on_delete=models.PROTECT, null=True, blank=True,
related_name='subevents')
items = models.ManyToManyField('Item', through='SubEventItem') items = models.ManyToManyField('Item', through='SubEventItem')
variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation') variations = models.ManyToManyField('ItemVariation', through='SubEventItemVariation')
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Date in event series") verbose_name = _("Date in event series")
verbose_name_plural = _("Dates in event series") verbose_name_plural = _("Dates in event series")
@@ -883,6 +949,28 @@ class SubEvent(EventMixin, LoggedModel):
def __str__(self): def __str__(self):
return '{} - {}'.format(self.name, self.get_date_range_display()) return '{} - {}'.format(self.name, self.get_date_range_display())
@property
def free_seats(self):
from .orders import CartPosition, Order, OrderPosition
return self.seats.annotate(
has_order=Exists(
OrderPosition.objects.filter(
order__event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
order__status__in=[Order.STATUS_PENDING, Order.STATUS_PAID]
)
),
has_cart=Exists(
CartPosition.objects.filter(
event_id=self.event_id,
subevent=self,
seat_id=OuterRef('pk'),
expires__gte=now()
)
)
).filter(has_order=False, has_cart=False, blocked=False)
@cached_property @cached_property
def settings(self): def settings(self):
return self.event.settings return self.event.settings
@@ -941,6 +1029,7 @@ class SubEvent(EventMixin, LoggedModel):
raise ValidationError(_('One or more variations do not belong to this event.')) raise ValidationError(_('One or more variations do not belong to this event.'))
@scopes_disabled()
def generate_invite_token(): def generate_invite_token():
return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits) return get_random_string(length=32, allowed_chars=string.ascii_lowercase + string.digits)

View File

@@ -1,6 +1,7 @@
import string import string
from decimal import Decimal from decimal import Decimal
import pycountry
from django.db import DatabaseError, models, transaction from django.db import DatabaseError, models, transaction
from django.db.models import Max from django.db.models import Max
from django.db.models.functions import Cast from django.db.models.functions import Cast
@@ -9,6 +10,9 @@ from django.utils.crypto import get_random_string
from django.utils.functional import cached_property from django.utils.functional import cached_property
from django.utils.translation import pgettext from django.utils.translation import pgettext
from django_countries.fields import CountryField from django_countries.fields import CountryField
from django_scopes import ScopedManager
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
def invoice_filename(instance, filename: str) -> str: def invoice_filename(instance, filename: str) -> str:
@@ -89,6 +93,7 @@ class Invoice(models.Model):
invoice_to_street = models.TextField(null=True) invoice_to_street = models.TextField(null=True)
invoice_to_zipcode = models.CharField(max_length=190, null=True) invoice_to_zipcode = models.CharField(max_length=190, null=True)
invoice_to_city = models.TextField(null=True) invoice_to_city = models.TextField(null=True)
invoice_to_state = models.CharField(max_length=190, null=True)
invoice_to_country = CountryField(null=True) invoice_to_country = CountryField(null=True)
invoice_to_vat_id = models.TextField(null=True) invoice_to_vat_id = models.TextField(null=True)
invoice_to_beneficiary = models.TextField(null=True) invoice_to_beneficiary = models.TextField(null=True)
@@ -107,6 +112,8 @@ class Invoice(models.Model):
file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255) file = models.FileField(null=True, blank=True, upload_to=invoice_filename, max_length=255)
internal_reference = models.TextField(blank=True) internal_reference = models.TextField(blank=True)
objects = ScopedManager(organizer='event__organizer')
@staticmethod @staticmethod
def _to_numeric_invoice_number(number): def _to_numeric_invoice_number(number):
return '{:05d}'.format(int(number)) return '{:05d}'.format(int(number))
@@ -137,11 +144,21 @@ class Invoice(models.Model):
def address_invoice_to(self): def address_invoice_to(self):
if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name: if self.invoice_to and not self.invoice_to_company and not self.invoice_to_name:
return self.invoice_to return self.invoice_to
state_name = ""
if self.invoice_to_state:
state_name = self.invoice_to_state
if str(self.invoice_to_country) in COUNTRIES_WITH_STATE_IN_ADDRESS:
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.invoice_to_country)][1] == 'long':
state_name = pycountry.subdivisions.get(
code='{}-{}'.format(self.invoice_to_country, self.invoice_to_state)
).name
parts = [ parts = [
self.invoice_to_company, self.invoice_to_company,
self.invoice_to_name, self.invoice_to_name,
self.invoice_to_street, self.invoice_to_street,
(self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or ""), ((self.invoice_to_zipcode or "") + " " + (self.invoice_to_city or "") + " " + (state_name or "")).strip(),
self.invoice_to_country.name if self.invoice_to_country else "", self.invoice_to_country.name if self.invoice_to_country else "",
] ]
return '\n'.join([p.strip() for p in parts if p and p.strip()]) return '\n'.join([p.strip() for p in parts if p and p.strip()])
@@ -172,6 +189,8 @@ class Invoice(models.Model):
self.organizer = self.order.event.organizer self.organizer = self.order.event.organizer
if not self.prefix: if not self.prefix:
self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-') self.prefix = self.event.settings.invoice_numbers_prefix or (self.event.slug.upper() + '-')
if self.is_cancellation:
self.prefix = self.event.settings.invoice_numbers_prefix_cancellations or self.prefix
if not self.invoice_no: if not self.invoice_no:
if self.order.testmode: if self.order.testmode:
self.prefix += 'TEST-' self.prefix += 'TEST-'

View File

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

View File

@@ -9,13 +9,14 @@ from decimal import Decimal
from typing import Any, Dict, List, Union from typing import Any, Dict, List, Union
import dateutil import dateutil
import pycountry
import pytz import pytz
from django.conf import settings from django.conf import settings
from django.db import models, transaction from django.db import models, transaction
from django.db.models import ( from django.db.models import (
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When, Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
) )
from django.db.models.functions import Coalesce from django.db.models.functions import Coalesce, Greatest
from django.db.models.signals import post_delete from django.db.models.signals import post_delete
from django.dispatch import receiver from django.dispatch import receiver
from django.urls import reverse from django.urls import reverse
@@ -26,10 +27,13 @@ from django.utils.functional import cached_property
from django.utils.timezone import make_aware, now from django.utils.timezone import make_aware, now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_countries.fields import Country, CountryField from django_countries.fields import Country, CountryField
from django_scopes import ScopedManager, scopes_disabled
from i18nfield.strings import LazyI18nString from i18nfield.strings import LazyI18nString
from jsonfallback.fields import FallbackJSONField from jsonfallback.fields import FallbackJSONField
from pretix.base.banlist import banned
from pretix.base.decimal import round_decimal from pretix.base.decimal import round_decimal
from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import User from pretix.base.models import User
from pretix.base.reldate import RelativeDateWrapper from pretix.base.reldate import RelativeDateWrapper
@@ -186,6 +190,8 @@ class Order(LockModel, LoggedModel):
verbose_name=_('E-mail address verified') verbose_name=_('E-mail address verified')
) )
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Order") verbose_name = _("Order")
verbose_name_plural = _("Orders") verbose_name_plural = _("Orders")
@@ -195,6 +201,8 @@ class Order(LockModel, LoggedModel):
return self.full_code return self.full_code
def gracefully_delete(self, user=None, auth=None): def gracefully_delete(self, user=None, auth=None):
from . import Voucher
if not self.testmode: if not self.testmode:
raise TypeError("Only test mode orders can be deleted.") raise TypeError("Only test mode orders can be deleted.")
self.event.log_action( self.event.log_action(
@@ -203,6 +211,12 @@ class Order(LockModel, LoggedModel):
'code': self.code, 'code': self.code,
} }
) )
if self.status != Order.STATUS_CANCELED:
for position in self.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=Greatest(0, F('redeemed') - 1))
OrderPosition.all.filter(order=self, addon_to__isnull=False).delete() OrderPosition.all.filter(order=self, addon_to__isnull=False).delete()
OrderPosition.all.filter(order=self).delete() OrderPosition.all.filter(order=self).delete()
OrderFee.all.filter(order=self).delete() OrderFee.all.filter(order=self).delete()
@@ -223,6 +237,7 @@ class Order(LockModel, LoggedModel):
return self.all_fees(manager='objects') return self.all_fees(manager='objects')
@cached_property @cached_property
@scopes_disabled()
def count_positions(self): def count_positions(self):
if hasattr(self, 'pcnt'): if hasattr(self, 'pcnt'):
return self.pcnt or 0 return self.pcnt or 0
@@ -246,6 +261,7 @@ class Order(LockModel, LoggedModel):
return None return None
@property @property
@scopes_disabled()
def payment_refund_sum(self): def payment_refund_sum(self):
payment_sum = self.payments.filter( payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED) state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
@@ -257,6 +273,7 @@ class Order(LockModel, LoggedModel):
return payment_sum - refund_sum return payment_sum - refund_sum
@property @property
@scopes_disabled()
def pending_sum(self): def pending_sum(self):
total = self.total total = self.total
if self.status == Order.STATUS_CANCELED: if self.status == Order.STATUS_CANCELED:
@@ -431,6 +448,7 @@ class Order(LockModel, LoggedModel):
return round_decimal(fee, self.event.currency) return round_decimal(fee, self.event.currency)
@property @property
@scopes_disabled()
def user_cancel_allowed(self) -> bool: def user_cancel_allowed(self) -> bool:
""" """
Returns whether or not this order can be canceled by the user. Returns whether or not this order can be canceled by the user.
@@ -519,16 +537,30 @@ class Order(LockModel, LoggedModel):
# handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that # handwriting (2/Z, 4/A, 5/S, 6/G). This allows for better detection e.g. in incoming wire transfers that
# might include OCR'd handwritten text # might include OCR'd handwritten text
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789') charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
iteration = 0
length = settings.ENTROPY['order_code']
while True: while True:
code = get_random_string(length=settings.ENTROPY['order_code'], allowed_chars=charset) code = get_random_string(length=length, allowed_chars=charset)
iteration += 1
if banned(code):
continue
if self.testmode: if self.testmode:
# Subtle way to recognize test orders while debugging: They all contain a 0 at the second place, # Subtle way to recognize test orders while debugging: They all contain a 0 at the second place,
# even though zeros are not used outside test mode. # even though zeros are not used outside test mode.
code = code[0] + "0" + code[2:] code = code[0] + "0" + code[2:]
if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists(): if not Order.objects.filter(event__organizer=self.event.organizer, code=code).exists():
self.code = code self.code = code
return return
if iteration > 20:
# Safeguard: If we don't find an unused and non-blacklisted code within 20 iterations, we increase
# the length.
length += 1
iteration = 0
@property @property
def can_modify_answers(self) -> bool: def can_modify_answers(self) -> bool:
""" """
@@ -615,7 +647,7 @@ class Order(LockModel, LoggedModel):
), tz) ), tz)
return term_last return term_last
def _can_be_paid(self, count_waitinglist=True, ignore_date=False) -> Union[bool, str]: def _can_be_paid(self, count_waitinglist=True, ignore_date=False, force=False) -> Union[bool, str]:
error_messages = { error_messages = {
'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the " 'late_lastdate': _("The payment can not be accepted as the last date of payments configured in the "
"payment settings is over."), "payment settings is over."),
@@ -623,29 +655,37 @@ class Order(LockModel, LoggedModel):
"payments should be accepted in the payment settings."), "payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.') 'require_approval': _('This order is not yet approved by the event organizer.')
} }
if self.require_approval: if not force:
return error_messages['require_approval'] if self.require_approval:
term_last = self.payment_term_last return error_messages['require_approval']
if term_last and not ignore_date: term_last = self.payment_term_last
if now() > term_last: if term_last and not ignore_date:
return error_messages['late_lastdate'] if now() > term_last:
return error_messages['late_lastdate']
if self.status == self.STATUS_PENDING: if self.status == self.STATUS_PENDING:
return True return True
if not self.event.settings.get('payment_term_accept_late') and not ignore_date: if not self.event.settings.get('payment_term_accept_late') and not ignore_date and not force:
return error_messages['late'] return error_messages['late']
return self._is_still_available(count_waitinglist=count_waitinglist) return self._is_still_available(count_waitinglist=count_waitinglist, force=force)
def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True) -> Union[bool, str]: def _is_still_available(self, now_dt: datetime=None, count_waitinglist=True, force=False) -> Union[bool, str]:
error_messages = { error_messages = {
'unavailable': _('The ordered product "{item}" is no longer available.'), 'unavailable': _('The ordered product "{item}" is no longer available.'),
'seat_unavailable': _('The seat "{seat}" is no longer available.'),
} }
now_dt = now_dt or now() now_dt = now_dt or now()
positions = self.positions.all().select_related('item', 'variation') positions = self.positions.all().select_related('item', 'variation', 'seat')
quota_cache = {} quota_cache = {}
try: try:
for i, op in enumerate(positions): for i, op in enumerate(positions):
if op.seat:
if not op.seat.is_available(ignore_orderpos=op):
raise Quota.QuotaExceededException(error_messages['seat_unavailable'].format(seat=op.seat))
if force:
continue
quotas = list(op.quotas) quotas = list(op.quotas)
if len(quotas) == 0: if len(quotas) == 0:
raise Quota.QuotaExceededException(error_messages['unavailable'].format( raise Quota.QuotaExceededException(error_messages['unavailable'].format(
@@ -673,7 +713,7 @@ class Order(LockModel, LoggedModel):
def send_mail(self, subject: str, template: Union[str, LazyI18nString], def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent', context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None, user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False, position: 'OrderPosition'=None): auth=None, attach_tickets=False, position: 'OrderPosition'=None, auto_email=True):
""" """
Sends an email to the user that placed this order. Basically, this method does two things: Sends an email to the user that placed this order. Basically, this method does two things:
@@ -713,7 +753,7 @@ class Order(LockModel, LoggedModel):
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.locale, self, headers=headers, sender=sender, self.event, self.locale, self, headers=headers, sender=sender,
invoices=invoices, attach_tickets=attach_tickets, invoices=invoices, attach_tickets=attach_tickets,
position=position position=position, auto_email=auto_email
) )
except SendMailException: except SendMailException:
raise raise
@@ -733,26 +773,9 @@ class Order(LockModel, LoggedModel):
) )
def resend_link(self, user=None, auth=None): def resend_link(self, user=None, auth=None):
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.locale): with language(self.locale):
try:
invoice_name = self.invoice_address.name
invoice_company = self.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = self.event.settings.mail_text_resend_link email_template = self.event.settings.mail_text_resend_link
email_context = { email_context = get_email_context(event=self.event, order=self)
'event': self.event.name,
'url': build_absolute_uri(self.event, 'presale:event.order.open', kwargs={
'order': self.code,
'secret': self.secret,
'hash': self.email_confirm_hash()
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
email_subject = _('Your order: %(code)s') % {'code': self.code} email_subject = _('Your order: %(code)s') % {'code': self.code}
self.send_mail( self.send_mail(
email_subject, email_template, email_context, email_subject, email_template, email_context,
@@ -814,6 +837,8 @@ class QuestionAnswer(models.Model):
max_length=255 max_length=255
) )
objects = ScopedManager(organizer='question__event__organizer')
@property @property
def backend_file_url(self): def backend_file_url(self):
if self.file: if self.file:
@@ -845,6 +870,10 @@ class QuestionAnswer(models.Model):
return url return url
return "" return ""
@property
def is_image(self):
return any(self.file.name.endswith(e) for e in ('.jpg', '.png', '.gif', '.tiff', '.bmp', '.jpeg'))
@property @property
def file_name(self): def file_name(self):
return self.file.name.split('.', 1)[-1] return self.file.name.split('.', 1)[-1]
@@ -921,6 +950,8 @@ class AbstractPosition(models.Model):
:type voucher: Voucher :type voucher: Voucher
:param meta_info: Additional meta information on the position, JSON-encoded. :param meta_info: Additional meta information on the position, JSON-encoded.
:type meta_info: str :type meta_info: str
:param seat: Seat, if reserved seating is used.
:type seat: Seat
""" """
subevent = models.ForeignKey( subevent = models.ForeignKey(
SubEvent, SubEvent,
@@ -967,6 +998,9 @@ class AbstractPosition(models.Model):
verbose_name=_("Meta information"), verbose_name=_("Meta information"),
null=True, blank=True null=True, blank=True
) )
seat = models.ForeignKey(
'Seat', null=True, blank=True, on_delete=models.PROTECT
)
class Meta: class Meta:
abstract = True abstract = True
@@ -1008,18 +1042,17 @@ class AbstractPosition(models.Model):
q.pk: q for q in questions q.pk: q for q in questions
} }
def question_is_visible(parentid, qval): def question_is_visible(parentid, qvals):
parentq = question_cache[parentid] parentq = question_cache[parentid]
if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_value): if parentq.dependency_question_id and not question_is_visible(parentq.dependency_question_id, parentq.dependency_values):
return False return False
if parentid not in self.answ: if parentid not in self.answ:
return False return False
if qval == 'True': return (
return self.answ[parentid].answer == 'True' ('True' in qvals and self.answ[parentid].answer == 'True')
elif qval == 'False': or ('False' in qvals and self.answ[parentid].answer == 'False')
return self.answ[parentid].answer == 'False' or (any(qval in [o.identifier for o in self.answ[parentid].options.all()] for qval in qvals))
else: )
return qval in [o.identifier for o in self.answ[parentid].options.all()]
self.questions = [] self.questions = []
for q in questions: for q in questions:
@@ -1028,7 +1061,7 @@ class AbstractPosition(models.Model):
q.answer.question = q # cache object q.answer.question = q # cache object
else: else:
q.answer = "" q.answer = ""
if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_value): if not q.dependency_question_id or question_is_visible(q.dependency_question_id, q.dependency_values):
self.questions.append(q) self.questions.append(q)
@property @property
@@ -1137,6 +1170,8 @@ class OrderPayment(models.Model):
) )
migrated = models.BooleanField(default=False) migrated = models.BooleanField(default=False)
objects = ScopedManager(organizer='order__event__organizer')
class Meta: class Meta:
ordering = ('local_id',) ordering = ('local_id',)
@@ -1153,19 +1188,19 @@ class OrderPayment(models.Model):
@info_data.setter @info_data.setter
def info_data(self, d): def info_data(self, d):
self.info = json.dumps(d) self.info = json.dumps(d, sort_keys=True)
@cached_property @cached_property
def payment_provider(self): def payment_provider(self):
""" """
Cached access to an instance of the payment provider in use. Cached access to an instance of the payment provider in use.
""" """
return self.order.event.get_payment_providers().get(self.provider) return self.order.event.get_payment_providers(cached=True).get(self.provider)
def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False): def _mark_paid(self, force, count_waitinglist, user, auth, ignore_date=False, overpaid=False):
from pretix.base.signals import order_paid from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date) can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist, ignore_date=ignore_date, force=force)
if not force and can_be_paid is not True: if can_be_paid is not True:
self.order.log_action('pretix.event.order.quotaexceeded', { self.order.log_action('pretix.event.order.quotaexceeded', {
'message': can_be_paid 'message': can_be_paid
}, user=user, auth=auth) }, user=user, auth=auth)
@@ -1277,24 +1312,10 @@ class OrderPayment(models.Model):
def _send_paid_mail_attendee(self, position, user): def _send_paid_mail_attendee(self, position, user):
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.order.locale): 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_template = self.order.event.settings.mail_text_order_paid_attendee
email_context = { email_context = get_email_context(event=self.order.event, order=self.order, position=position)
'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} email_subject = _('Event registration confirmed: %(code)s') % {'code': self.order.code}
try: try:
self.order.send_mail( self.order.send_mail(
@@ -1308,28 +1329,10 @@ class OrderPayment(models.Model):
def _send_paid_mail(self, invoice, user, mail_text): def _send_paid_mail(self, invoice, user, mail_text):
from pretix.base.services.mail import SendMailException from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
with language(self.order.locale): 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_template = self.order.event.settings.mail_text_order_paid
email_context = { email_context = get_email_context(event=self.order.event, order=self.order, payment_info=mail_text)
'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} email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
try: try:
self.order.send_mail( self.order.send_mail(
@@ -1493,6 +1496,8 @@ class OrderRefund(models.Model):
null=True, blank=True null=True, blank=True
) )
objects = ScopedManager(organizer='order__event__organizer')
class Meta: class Meta:
ordering = ('local_id',) ordering = ('local_id',)
@@ -1509,7 +1514,7 @@ class OrderRefund(models.Model):
@info_data.setter @info_data.setter
def info_data(self, d): def info_data(self, d):
self.info = json.dumps(d) self.info = json.dumps(d, sort_keys=True)
@cached_property @cached_property
def payment_provider(self): def payment_provider(self):
@@ -1554,7 +1559,7 @@ class OrderRefund(models.Model):
super().save(*args, **kwargs) super().save(*args, **kwargs)
class ActivePositionManager(models.Manager): class ActivePositionManager(ScopedManager(organizer='order__event__organizer').__class__):
def get_queryset(self): def get_queryset(self):
return super().get_queryset().filter(canceled=False) return super().get_queryset().filter(canceled=False)
@@ -1631,7 +1636,7 @@ class OrderFee(models.Model):
) )
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = models.Manager() all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager() objects = ActivePositionManager()
@property @property
@@ -1736,7 +1741,7 @@ class OrderPosition(AbstractPosition):
) )
canceled = models.BooleanField(default=False) canceled = models.BooleanField(default=False)
all = models.Manager() all = ScopedManager(organizer='order__event__organizer')
objects = ActivePositionManager() objects = ActivePositionManager()
class Meta: class Meta:
@@ -1836,6 +1841,7 @@ class OrderPosition(AbstractPosition):
return super().save(*args, **kwargs) return super().save(*args, **kwargs)
@scopes_disabled()
def assign_pseudonymization_id(self): def assign_pseudonymization_id(self):
# This omits some character pairs completely because they are hard to read even on screens (1/I and O/0) # This omits some character pairs completely because they are hard to read even on screens (1/I and O/0)
# and includes only one of two characters for some pairs because they are sometimes hard to distinguish in # and includes only one of two characters for some pairs because they are sometimes hard to distinguish in
@@ -1875,25 +1881,26 @@ class OrderPosition(AbstractPosition):
""" """
from pretix.base.services.mail import SendMailException, mail, render_mail from pretix.base.services.mail import SendMailException, mail, render_mail
if not self.email: if not self.attendee_email:
return return
for k, v in self.event.meta_data.items(): for k, v in self.event.meta_data.items():
context['meta_' + k] = v context['meta_' + k] = v
with language(self.locale): with language(self.order.locale):
recipient = self.email recipient = self.attendee_email
try: try:
email_content = render_mail(template, context) email_content = render_mail(template, context)
mail( mail(
recipient, subject, template, context, recipient, subject, template, context,
self.event, self.locale, self, headers, sender, self.event, self.order.locale, order=self.order, headers=headers, sender=sender,
position=self,
invoices=invoices, attach_tickets=attach_tickets invoices=invoices, attach_tickets=attach_tickets
) )
except SendMailException: except SendMailException:
raise raise
else: else:
self.log_action( self.order.log_action(
log_entry_type, log_entry_type,
user=user, user=user,
auth=auth, auth=auth,
@@ -1906,6 +1913,18 @@ class OrderPosition(AbstractPosition):
} }
) )
def resend_link(self, user=None, auth=None):
with language(self.order.locale):
email_template = self.event.settings.mail_text_resend_link
email_context = get_email_context(event=self.order.event, order=self.order, position=self)
email_subject = _('Your event registration: %(code)s') % {'code': self.order.code}
self.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.resend', user=user, auth=auth,
attach_tickets=True
)
class CartPosition(AbstractPosition): class CartPosition(AbstractPosition):
""" """
@@ -1943,6 +1962,8 @@ class CartPosition(AbstractPosition):
) )
is_bundled = models.BooleanField(default=False) is_bundled = models.BooleanField(default=False)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Cart position") verbose_name = _("Cart position")
verbose_name_plural = _("Cart positions") verbose_name_plural = _("Cart positions")
@@ -1979,6 +2000,7 @@ class InvoiceAddress(models.Model):
city = models.CharField(max_length=255, verbose_name=_('City'), blank=False) city = models.CharField(max_length=255, verbose_name=_('City'), blank=False)
country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False) country_old = models.CharField(max_length=255, verbose_name=_('Country'), blank=False)
country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country')) country = CountryField(verbose_name=_('Country'), blank=False, blank_label=_('Select country'))
state = models.CharField(max_length=255, verbose_name=pgettext_lazy('address', 'State'), blank=True)
vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'), vat_id = models.CharField(max_length=255, blank=True, verbose_name=_('VAT ID'),
help_text=_('Only for business customers within the EU.')) help_text=_('Only for business customers within the EU.'))
vat_id_validated = models.BooleanField(default=False) vat_id_validated = models.BooleanField(default=False)
@@ -1992,6 +2014,8 @@ class InvoiceAddress(models.Model):
blank=True blank=True
) )
objects = ScopedManager(organizer='order__event__organizer')
def save(self, **kwargs): def save(self, **kwargs):
if self.order: if self.order:
self.order.touch() self.order.touch()
@@ -2003,6 +2027,22 @@ class InvoiceAddress(models.Model):
self.name_parts = {} self.name_parts = {}
super().save(**kwargs) super().save(**kwargs)
@property
def state_name(self):
sd = pycountry.subdivisions.get(code='{}-{}'.format(self.country, self.state))
if sd:
return sd.name
return self.state
@property
def state_for_address(self):
from pretix.base.settings import COUNTRIES_WITH_STATE_IN_ADDRESS
if not self.state or str(self.country) not in COUNTRIES_WITH_STATE_IN_ADDRESS:
return ""
if COUNTRIES_WITH_STATE_IN_ADDRESS[str(self.country)][1] == 'long':
return self.state_name
return self.state
@property @property
def name(self): def name(self):
if not self.name_parts: if not self.name_parts:

View File

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

View File

@@ -85,6 +85,12 @@ EU_CURRENCIES = {
} }
def cc_to_vat_prefix(country_code):
if country_code == 'GR':
return 'EL'
return country_code
class TaxRule(LoggedModel): class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE) event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
name = I18nCharField( name = I18nCharField(

View File

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

View File

@@ -4,11 +4,12 @@ from django.core.exceptions import ValidationError
from django.db import models, transaction from django.db import models, transaction
from django.utils.timezone import now from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _ from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from django_scopes import ScopedManager
from pretix.base.email import get_email_context
from pretix.base.i18n import language from pretix.base.i18n import language
from pretix.base.models import Voucher from pretix.base.models import Voucher
from pretix.base.services.mail import mail from pretix.base.services.mail import mail
from pretix.multidomain.urlreverse import build_absolute_uri
from .base import LoggedModel from .base import LoggedModel
from .event import Event, SubEvent from .event import Event, SubEvent
@@ -67,6 +68,8 @@ class WaitingListEntry(LoggedModel):
) )
priority = models.IntegerField(default=0) priority = models.IntegerField(default=0)
objects = ScopedManager(organizer='event__organizer')
class Meta: class Meta:
verbose_name = _("Waiting list entry") verbose_name = _("Waiting list entry")
verbose_name_plural = _("Waiting list entries") verbose_name_plural = _("Waiting list entries")
@@ -127,13 +130,7 @@ class WaitingListEntry(LoggedModel):
self.email, self.email,
_('You have been selected from the waitinglist for {event}').format(event=str(self.event)), _('You have been selected from the waitinglist for {event}').format(event=str(self.event)),
self.event.settings.mail_text_waiting_list, self.event.settings.mail_text_waiting_list,
{ get_email_context(event=self.event, waiting_list_entry=self),
'event': self.event.name,
'url': build_absolute_uri(self.event, 'presale:event.redeem') + '?voucher=' + self.voucher.code,
'code': self.voucher.code,
'product': str(self.item) + (' - ' + str(self.variation) if self.variation else ''),
'hours': self.event.settings.waiting_list_hours,
},
self.event, self.event,
locale=self.locale locale=self.locale
) )

View File

@@ -249,9 +249,7 @@ class BasePaymentProvider:
('_fee_percent', ('_fee_percent',
forms.DecimalField( forms.DecimalField(
label=_('Additional fee'), label=_('Additional fee'),
help_text=_('Percentage of the order total. Note that this percentage will currently only ' help_text=_('Percentage of the order total.'),
'be calculated on the summed price of sold tickets, not on other fees like e.g. shipping '
'fees, if there are any.'),
localize=True, localize=True,
required=False, required=False,
)), )),
@@ -298,11 +296,12 @@ class BasePaymentProvider:
""" """
return "" return ""
def render_invoice_text(self, order: Order) -> str: def render_invoice_text(self, order: Order, payment: OrderPayment) -> str:
""" """
This is called when an invoice for an order with this payment provider is generated. This is called when an invoice for an order with this payment provider is generated.
The default implementation returns the content of the _invoice_text configuration The default implementation returns the content of the _invoice_text configuration
variable (an I18nString), or an empty string if unconfigured. variable (an I18nString), or an empty string if unconfigured. For paid orders, the
default implementation always renders a string stating that the invoice is already paid.
""" """
if order.status == Order.STATUS_PAID: if order.status == Order.STATUS_PAID:
return pgettext_lazy('invoice', 'The payment for this invoice has already been received.') return pgettext_lazy('invoice', 'The payment for this invoice has already been received.')
@@ -532,7 +531,7 @@ class BasePaymentProvider:
containing the URL the user will be redirected to. If you are done with your process containing the URL the user will be redirected to. If you are done with your process
you should return the user to the order's detail page. you should return the user to the order's detail page.
If the payment is completed, you should call ``payment.confirm()``. Please note that ``this`` might If the payment is completed, you should call ``payment.confirm()``. Please note that this might
raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and raise a ``Quota.QuotaExceededException`` if (and only if) the payment term of this order is over and
some of the items are sold out. You should use the exception message to display a meaningful error some of the items are sold out. You should use the exception message to display a meaningful error
to the user. to the user.
@@ -547,13 +546,14 @@ class BasePaymentProvider:
""" """
return None return None
def order_pending_mail_render(self, order: Order) -> str: def order_pending_mail_render(self, order: Order, payment: OrderPayment) -> str:
""" """
After the user has submitted their order, they will receive a confirmation After the user has submitted their order, they will receive a confirmation
email. You can return a string from this method if you want to add additional email. You can return a string from this method if you want to add additional
information to this email. information to this email.
:param order: The order object :param order: The order object
:param payment: The payment object
""" """
return "" return ""
@@ -657,6 +657,15 @@ class BasePaymentProvider:
obj.info = '{}' obj.info = '{}'
obj.save(update_fields=['info']) obj.save(update_fields=['info'])
def api_payment_details(self, payment: OrderPayment):
"""
Will be called to populate the ``details`` parameter of the payment in the REST API.
:param payment: The payment in question.
:return: A serializable dictionary
"""
return {}
class PaymentException(Exception): class PaymentException(Exception):
pass pass
@@ -720,6 +729,12 @@ class BoxOfficeProvider(BasePaymentProvider):
def order_change_allowed(self, order: Order) -> bool: def order_change_allowed(self, order: Order) -> bool:
return False return False
def api_payment_details(self, payment: OrderPayment):
return {
"pos_id": payment.info_data.get('pos_id', None),
"receipt_id": payment.info_data.get('receipt_id', None),
}
def payment_control_render(self, request, payment) -> str: def payment_control_render(self, request, payment) -> str:
if not payment.info: if not payment.info:
return return
@@ -864,6 +879,11 @@ class OffsettingProvider(BasePaymentProvider):
def order_change_allowed(self, order: Order) -> bool: def order_change_allowed(self, order: Order) -> bool:
return False return False
def api_payment_details(self, payment: OrderPayment):
return {
"orders": payment.info_data.get('orders', []),
}
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str: def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders'])) return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))

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