Compare commits

...

342 Commits

Author SHA1 Message Date
Raphael Michel
91c02dc0b3 Bump version to 2.1.0 2018-10-04 11:33:09 +02:00
Raphael Michel
f78ec830b5 Fix pretix-stripe.js 2018-10-03 17:31:06 +02:00
Raphael Michel
9f0e508ab3 Do not require meta_noindex 2018-10-03 12:52:37 +02:00
Raphael Michel
4ca50d750b Merge pull request #1037 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-10-03 12:44:14 +02:00
Raphael Michel
07c1b1b7f3 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2773 of 2773 strings)

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

powered by weblate
2018-10-03 10:43:50 +00:00
Raphael Michel
3e95dd52cf Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2773 of 2773 strings)

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

powered by weblate
2018-10-03 10:43:35 +00:00
Raphael Michel
80ef2f6b0e Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2773 of 2773 strings)

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

powered by weblate
2018-10-03 10:38:42 +00:00
Raphael Michel
53a8cda310 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-10-03 12:25:02 +02:00
Raphael Michel
63de49104c Merge pull request #1016 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-10-03 12:24:28 +02:00
Maarten van den Berg
8aa80bcb84 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2727 of 2727 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
oocf
95115a7c5e Translated on translate.pretix.eu (Spanish)
Currently translated at 99.9% (2725 of 2727 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
oocf
ce2967fd02 Translated on translate.pretix.eu (Spanish)
Currently translated at 99.9% (2725 of 2727 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
oocf
399fb87d20 Translated on translate.pretix.eu (Spanish)
Currently translated at 99.7% (2719 of 2727 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
oocf
c4bd5ac5df Translated on translate.pretix.eu (Spanish)
Currently translated at 99.7% (2719 of 2727 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
Maarten van den Berg
123c2d6c02 Translated on translate.pretix.eu (Dutch)
Currently translated at 99.4% (2711 of 2727 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
Maarten van den Berg
6954e9c984 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
Yunus Fırat Pişkin
fc573e4e48 Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (2727 of 2727 strings)

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

powered by weblate
2018-10-03 10:15:55 +00:00
Raphael Michel
0dbcfdc5ac Allow to enable ticket downloads for pending orders 2018-10-03 12:15:43 +02:00
Raphael Michel
4b8d4b4792 Allow to bulk-delete vouchers 2018-10-03 11:32:55 +02:00
Raphael Michel
d798da33ef Add option to add robots=noindex meta tag 2018-10-03 11:15:59 +02:00
Raphael Michel
d99517c8d1 Fix #917 -- Attach tickets to emails (#1034) 2018-10-03 11:06:50 +02:00
Raphael Michel
0787adcb8e Fix AttributeError in paypal module 2018-10-02 17:12:26 +02:00
Raphael Michel
f848561d25 Expose log details for admins 2018-10-01 14:13:44 +02:00
Raphael Michel
efbddc2486 Log failed payments 2018-10-01 13:48:47 +02:00
Raphael Michel
e6a138d8f2 Bank transfer: Use correct attribute 2018-10-01 13:05:17 +02:00
Raphael Michel
5b7a578307 Improve display of stripe transaction data 2018-10-01 12:47:36 +02:00
Raphael Michel
737738de93 Fix control display of bank transfers 2018-10-01 12:43:12 +02:00
Raphael Michel
eb3951ce13 Fix waiting list action view without return value 2018-10-01 12:43:12 +02:00
Raphael Michel
c2b7d9a257 Fix transaction handling in invite form 2018-09-30 14:07:14 +02:00
Raphael Michel
4738aa2771 Fix contextual table styles 2018-09-30 13:11:33 +02:00
Raphael Michel
29ac0af55e Improve Device.__str__ method 2018-09-28 16:33:15 +02:00
Raphael Michel
96bc64c456 Do not break invoices if order has no locale 2018-09-27 17:15:49 +02:00
Raphael Michel
0369deb72d Fix permission for access to root event resource 2018-09-27 10:01:57 +02:00
Raphael Michel
6e53990845 Make last commit more resilient 2018-09-25 18:20:40 +02:00
Raphael Michel
feb262644e Orders API: Reduce query load imposed by ?pdf_data=true by multiple orders of magnitude 2018-09-25 17:39:58 +02:00
Raphael Michel
abd679820f Merge pull request #1017 from pretix/deviceauth
Authentication scheme for devices
2018-09-25 14:36:23 +02:00
Raphael Michel
cd3ce848d1 Document permissions 2018-09-25 12:30:15 +02:00
Raphael Michel
63ba393c12 Proper permission handling and testing 2018-09-25 12:29:05 +02:00
Raphael Michel
23fdf8c457 Add compatibility note 2018-09-25 12:12:33 +02:00
Raphael Michel
304ad4e3db Restrict list of events 2018-09-25 10:54:36 +02:00
Raphael Michel
ec58ab07b6 Add tests for control 2018-09-25 10:28:07 +02:00
Raphael Michel
1ba4047b1b API-level tests 2018-09-25 10:28:07 +02:00
Raphael Michel
0bab8adc41 Add documentation on auth 2018-09-25 10:28:07 +02:00
Raphael Michel
17e09c601e Revoke + Logging 2018-09-25 10:28:07 +02:00
Raphael Michel
1aca5fb6ff Fix wrong action parameter 2018-09-25 10:28:07 +02:00
Raphael Michel
7860d690fa Add endpoints to update, roll and revoke devices 2018-09-25 10:28:07 +02:00
Raphael Michel
6d01c99d38 Auth mechanism 2018-09-25 10:28:07 +02:00
Raphael Michel
ddb645aeea Creating device objects 2018-09-25 10:28:07 +02:00
Raphael Michel
f08e4b41c4 Data model 2018-09-25 10:28:07 +02:00
Raphael Michel
1e23624955 Fix #1032 -- Workaround for markdown version 2018-09-24 14:07:11 +02:00
Raphael Michel
ee951a7448 API: Add subevent list on organizer level 2018-09-24 12:59:44 +02:00
Raphael Michel
9935ba370d Event list API: Do not show events without any access permissions 2018-09-24 12:44:45 +02:00
Raphael Michel
e815cce143 Event list API: Add filters 2018-09-24 12:36:12 +02:00
Raphael Michel
cea1032180 SplitDateTimeField: Adjust placeholders to actual locale 2018-09-21 16:54:22 +02:00
Raphael Michel
5695e1d9c8 SplitDateTimeField: Consider field empty if only a time is given 2018-09-21 16:54:22 +02:00
Raphael Michel
fd317afd01 Improve accessibility of payment selection 2018-09-21 16:54:22 +02:00
Raphael Michel
ccddd2a96f Activate passbook by default if installed 2018-09-21 16:54:22 +02:00
Raphael Michel
513d3034d8 Remove deprecated template part 2018-09-20 21:12:49 +02:00
Raphael Michel
51495187fa Merge pull request #1028 from chrko/error_pages_html
Fix outside of body script element
2018-09-20 10:08:11 +02:00
Christian Kohlstedde
2bd53f7b9f Fix outside of body script element
Signed-off-by: Christian Kohlstedde <christian@kohlsted.de>
2018-09-20 10:00:55 +02:00
Raphael Michel
06d9c48ed4 Allow to restrict payment methods by invoice address country 2018-09-19 16:10:40 +02:00
Raphael Michel
1155d18b7f Show waiting list options even when waiting list is disabled 2018-09-19 15:44:17 +02:00
Raphael Michel
6e14592c78 Delete check-ins when deleting a check-in list 2018-09-19 15:41:49 +02:00
Raphael Michel
55feaf2d2c Invoices: Your reference → Customer reference 2018-09-19 15:40:50 +02:00
Raphael Michel
c487036c8b Fix bug in thumbnail generation of small images 2018-09-19 15:38:12 +02:00
Raphael Michel
853ebf8c70 Fix Sphinx warnings 2018-09-19 14:00:01 +02:00
Raphael Michel
1c695c1cf9 Remove unused resource from docs 2018-09-19 13:59:15 +02:00
Raphael Michel
bd5687d169 Remove lock when paying a pending order 2018-09-17 13:04:49 +02:00
Raphael Michel
b384f71b64 Fail silently if attachment could not be found 2018-09-13 12:58:08 +02:00
Raphael Michel
10dd5278e7 Fix bug in previous commit 2018-09-13 12:32:07 +02:00
Raphael Michel
befa6527e4 Attach invoice to order approval email 2018-09-13 12:19:30 +02:00
Raphael Michel
00497630cb Merge pull request #1015 from thegcat/patch-1
Correct typo
2018-09-12 08:38:56 +02:00
Felix Schäfer
95cd457de1 Correct typo
The Header is `Content-Type` not `Content`.
2018-09-11 22:11:48 +02:00
Raphael Michel
7518c9e3e0 Do not install python34.txt on release CI 2018-09-11 18:24:00 +02:00
Raphael Michel
6a999835e2 Bump version 2018-09-11 18:23:10 +02:00
Raphael Michel
41d099c1be Bump version 2018-09-11 18:16:50 +02:00
Raphael Michel
ff306ce2c5 Fix isort 2018-09-11 18:07:27 +02:00
Raphael Michel
c7abc82055 Add squashed migrations 2018-09-11 17:18:20 +02:00
Raphael Michel
041d91dd3c Merge pull request #1013 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-09-11 17:18:13 +02:00
Raphael Michel
387f56ed9b Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2727 of 2727 strings)

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

powered by weblate
2018-09-11 14:59:05 +00:00
Raphael Michel
3181323c1f Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2727 of 2727 strings)

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

powered by weblate
2018-09-11 14:58:17 +00:00
Raphael Michel
ecf84150c1 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-09-11 16:43:17 +02:00
Raphael Michel
5b5025c776 Allow to manually revert check-ins on a check-in list 2018-09-11 15:21:22 +02:00
Raphael Michel
e47dd3058b Adjust tests to error messages 2018-09-11 09:02:08 +02:00
Raphael Michel
71f1dcd475 Fix #1009 -- Fix missing file include in MANIFEST.in 2018-09-11 08:58:32 +02:00
Raphael Michel
941856932c Documentation improvements 2018-09-11 08:58:14 +02:00
Raphael Michel
c51fde52e7 Merge pull request #1008 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-09-10 18:16:00 +02:00
Raphael Michel
c5362e3bde Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2724 of 2724 strings)

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

powered by weblate
2018-09-10 16:15:30 +00:00
Raphael Michel
a113703451 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2724 of 2724 strings)

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

powered by weblate
2018-09-10 16:14:40 +00:00
Raphael Michel
55ecb918e9 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-09-10 18:04:58 +02:00
Raphael Michel
3a870e2f8b Merge pull request #1004 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-09-10 18:04:20 +02:00
oocf
734231a4f1 Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
oocf
223d6b29f4 Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (2720 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
oocf
4f41ec0a97 Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (2720 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
oocf
347a53297d Translated on translate.pretix.eu (Spanish)
Currently translated at 99.7% (2712 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
oocf
820766abcb Translated on translate.pretix.eu (Spanish)
Currently translated at 23.5% (640 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
oocf
4974fa1fed Translated on translate.pretix.eu (Spanish)
Currently translated at 17.2% (468 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
Yunus Fırat Pişkin
7e829fa204 Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (2720 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
Yunus Fırat Pişkin
f6c7caa48d Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (65 of 65 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
Yunus Fırat Pişkin
0dd9d252fd Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (2720 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
Yunus Fırat Pişkin
39f67a241c Translated on translate.pretix.eu (Turkish)
Currently translated at 96.4% (2623 of 2720 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
Yunus Fırat Pişkin
5706b08366 Translated on translate.pretix.eu (Turkish)
Currently translated at 96.9% (63 of 65 strings)

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

powered by weblate
2018-09-10 16:00:42 +00:00
Raphael Michel
81de9695e2 Add a more specific message on locked vouchers 2018-09-10 17:54:54 +02:00
Raphael Michel
589fb25fe3 Warn about variations without quota 2018-09-10 17:44:50 +02:00
Raphael Michel
61e5c6b468 Fix bug editing addon products 2018-09-10 17:40:56 +02:00
Raphael Michel
087ceb3687 Fix waiting list widgets with infinite quotas 2018-09-04 13:59:40 +02:00
Raphael Michel
0a2cd208b2 Fix invalid tests 2018-09-03 16:55:04 +02:00
Raphael Michel
678a936897 Fix #999 -- Clarify definition of overpaid 2018-09-03 16:30:23 +02:00
Raphael Michel
7c72ca089b Do not allow to mark an order as unpaid 2018-09-03 15:41:18 +02:00
Raphael Michel
21530f315f Properly restrict refunds to full payment amount 2018-09-03 15:41:05 +02:00
Raphael Michel
7274905a92 Ensure correct order of refund log 2018-09-03 15:25:28 +02:00
Raphael Michel
6c5cff6162 Stripe: Do not duplicate refunds of migrated payments 2018-09-03 15:20:05 +02:00
Raphael Michel
cf6b6c129a Stripe: Store refund details 2018-09-03 15:19:56 +02:00
Raphael Michel
74491d16ae Fix a resolver error 2018-09-02 19:54:36 +02:00
Raphael Michel
c1ab6e4eb4 Merge pull request #1003 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-09-02 16:28:17 +02:00
Raphael Michel
18c9ae235a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2720 of 2720 strings)

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

powered by weblate
2018-09-02 14:27:44 +00:00
Raphael Michel
5c69d5fb88 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2720 of 2720 strings)

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

powered by weblate
2018-09-02 14:26:06 +00:00
Raphael Michel
90f0bda879 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-09-02 15:50:07 +02:00
Martin Gross
1b5c4a21bb Show download-provider specific icons where defined 2018-09-02 15:49:35 +02:00
Raphael Michel
08ee37112f Merge pull request #995 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-09-02 15:48:48 +02:00
Yunus Fırat Pişkin
cfbc88d3d6 Translated on translate.pretix.eu (Turkish)
Currently translated at 92.8% (2517 of 2712 strings)

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

powered by weblate
2018-09-02 13:46:46 +00:00
Raphael Michel
79f5529a5a Translated on translate.pretix.eu (Turkish)
Currently translated at 92.8% (2517 of 2712 strings)

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

powered by weblate
2018-09-02 13:46:36 +00:00
Yunus Fırat Pişkin
11ed0abd18 Translated on translate.pretix.eu (Turkish)
Currently translated at 92.8% (2517 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
01830d9910 Translated on translate.pretix.eu (Turkish)
Currently translated at 92.0% (2495 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
0f573805f2 Translated on translate.pretix.eu (Turkish)
Currently translated at 85.2% (2310 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
93b1d81a48 Translated on translate.pretix.eu (Turkish)
Currently translated at 83.8% (2274 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
e28d13b910 Translated on translate.pretix.eu (Turkish)
Currently translated at 81.6% (2212 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
8731e343c4 Translated on translate.pretix.eu (Turkish)
Currently translated at 75.6% (2051 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
605eca8cd7 Translated on translate.pretix.eu (Spanish)
Currently translated at 17.1% (464 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
5a8ddf5e4a Translated on translate.pretix.eu (Spanish)
Currently translated at 16.2% (438 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
f6d5d575fc Translated on translate.pretix.eu (Turkish)
Currently translated at 73.3% (1989 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
d5c344e3ac Translated on translate.pretix.eu (Turkish)
Currently translated at 71.6% (1941 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
18ba326cea Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (64 of 64 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
1a1473d3ba Translated on translate.pretix.eu (Spanish)
Currently translated at 14.9% (403 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Dimas 3r1ck Rivas
72804a09ec Translated on translate.pretix.eu (Spanish)
Currently translated at 13.0% (353 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
c1ce0a514c Translated on translate.pretix.eu (Spanish)
Currently translated at 13.0% (353 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
bd479312b5 Translated on translate.pretix.eu (Spanish)
Currently translated at 12.9% (350 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
469da540d2 Translated on translate.pretix.eu (Spanish)
Currently translated at 9.2% (250 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
69edaa974f Translated on translate.pretix.eu (Spanish)
Currently translated at 6.6% (178 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
oocf
ff56963040 Translated on translate.pretix.eu (Spanish)
Currently translated at 100.0% (64 of 64 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
266aeaef50 Translated on translate.pretix.eu (Turkish)
Currently translated at 66.9% (1813 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
fc660cfb1f Translated on translate.pretix.eu (Turkish)
Currently translated at 66.4% (1801 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Maarten van den Berg
27d343bdea Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (64 of 64 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Maarten van den Berg
a04b0da54a Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2712 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Maarten van den Berg
b15a6bfa98 Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (64 of 64 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Maarten van den Berg
dcc638c12f Translated on translate.pretix.eu (Dutch)
Currently translated at 100.0% (2712 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
84ea96a5ad Translated on translate.pretix.eu (Turkish)
Currently translated at 66.0% (1789 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
ae1bf85740 Translated on translate.pretix.eu (Turkish)
Currently translated at 98.4% (63 of 64 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Yunus Fırat Pişkin
1612d713c9 Translated on translate.pretix.eu (Turkish)
Currently translated at 56.0% (1519 of 2712 strings)

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

powered by weblate
2018-09-02 13:25:51 +00:00
Raphael Michel
6a4a8af731 Improve cookie detection and handling 2018-09-02 15:25:33 +02:00
Raphael Michel
e18375ca6d Avoid conflict in saving objects 2018-08-31 14:05:25 +02:00
Raphael Michel
e537e4538a Fix limits for manual payment 2018-08-31 13:06:13 +02:00
Raphael Michel
1ae97f5477 API: Allow to filter order positions by voucher 2018-08-31 12:53:37 +02:00
Raphael Michel
cc0083c6e5 Allow to search by voucher in check-in list 2018-08-31 12:50:50 +02:00
Raphael Michel
43e6ed2da9 Check-in list PDF: Deal with very long questions and answers 2018-08-31 12:35:28 +02:00
Raphael Michel
27bb3a948b Fix descending sorting of subevents 2018-08-31 12:22:31 +02:00
Raphael Michel
7c155d307b Return 404 for invalid check-in list ID 2018-08-31 12:16:15 +02:00
Raphael Michel
d789beddd0 Fix ValueError on change of payment method
Fixes Sentry issue PRETIXEU-KX
2018-08-31 11:15:59 +02:00
Raphael Michel
f790148ad3 Statistics: Fix AttributeError with subevents 2018-08-31 11:05:17 +02:00
Lukas Bockstaller
a643abe293 Prevent email enumeration (#1000)
Here is my attempt to prevent user enumeration. 
I've made the following changes:

**Application:**
- replaces success and failure messages in the form with two (with/without redis) information messages 
- adds logging for attempted password resets of unknown users
- adds logging for failing emails

**Tests:**
- test_unknown asserts a redirect instead of a ok
- adds test_email_reset_twice_redis to assert the correct logging of a twice reset email 
- adds a FakeRedis class similiar to the one implemented in test_metrics.py. I could refactor them into the testutils folder if prefered. 

Please excuse the commit mess. I am currently fighting with my tooling.
2018-08-31 10:28:39 +02:00
Raphael Michel
099b08f009 Move redis depencency to production.txt 2018-08-31 09:16:01 +02:00
Raphael Michel
35ddf6790e Add mark_refunded parameter to refund creation 2018-08-21 15:48:22 +02:00
Raphael Michel
6502fdb1f5 Allow to switch to admin mode on 404 and 403 page 2018-08-21 15:13:00 +02:00
Raphael Michel
b5cd3bf0af Do not send paid email for free orders 2018-08-21 11:03:56 +02:00
Felix Rindt
8183648902 Rename module async to tasks (#994)
Fixes #993
2018-08-21 10:53:40 +02:00
Raphael Michel
0e1159b01e Allow to disable plugins system-wide 2018-08-19 15:12:58 +02:00
Raphael Michel
625ef3da8a Round decimal on dashboard 2018-08-19 15:09:59 +02:00
Raphael Michel
10c7d9a6e1 Statistics: Prevent issues with async script loading 2018-08-17 11:12:19 +02:00
Raphael Michel
85952ce6b7 Widget: Put overlay directly in <body> to avoid z-index problems 2018-08-17 11:02:44 +02:00
Raphael Michel
bf9ce68d8b Fix test for free to paid 2018-08-17 09:18:05 +02:00
Raphael Michel
08c5992447 OrderChangeManager: Allow free→paid 2018-08-17 09:16:48 +02:00
Raphael Michel
dfc7f7c827 Widget: Pass cart IDs around in some more places 2018-08-16 18:40:21 +02:00
Raphael Michel
efdbbc6098 Do not pass signature in plaintext to renderer 2018-08-16 13:48:37 +02:00
Raphael Michel
185cf90d4c Fix the readthedocs build 2018-08-16 12:04:07 +02:00
Raphael Michel
4db4790270 Custom HTML email renderers and new email style (#991)
* Custom HTML email renderers

* Move inline_css call

* Small fixes

* New HTML mail style for pretix

* Thumbs

* Inlinestyle for notifications

* Documentation

* Set line-height
2018-08-16 12:01:23 +02:00
Raphael Michel
be3b890e2f PayPal error handling 2018-08-16 09:36:16 +02:00
Raphael Michel
4536f96493 Only mark apple pay domains as stored in live mode 2018-08-15 14:14:35 +02:00
Raphael Michel
a598c3e7a8 Stripe: Catch exceptions when filling countries 2018-08-15 11:25:47 +02:00
Raphael Michel
d9f5ee9d76 Stripe: Smoother animation 2018-08-15 09:55:39 +02:00
Martin Gross
a4ced609cd Stripe: ApplePay/Payment Request Button (#988)
As discussed, this is a WIP for integrating Stripe's Payment Request Buttons (with also includes the ApplePay-Button on iOS-devices).

Todos:
- [x] Payment Request Button is still displayed, even when a card has already been tokenized (when going back in the order-flow)
- [x] The domains used need to be verified using the Stripe API to enable ApplePay: https://stripe.com/docs/stripe-js/elements/payment-request-button#verifying-your-domain-with-apple-pay
- [x] Migration: Get the account-country for existing Stripe Connect users
- [x] Migration: Verify the domains using the above mentioned API for existing users
- [x] Converting the chargeable amount is not right for non-decimal currencies like JPY

Other considerations:
- On iOS-devices using Safari (probably also on MacBooks, etc. - not tested), the [regular payment request button](https://user-images.githubusercontent.com/157270/38515749-f53f8392-3be9-11e8-8917-61ef78dd354a.png) is automatically replaced with a [buy with Apple Pay button](https://docs-assets.developer.apple.com/published/094d0eb90e/988c36a8-a43c-4ff9-85ef-beda16c4b7c9.png).
- On all other platforms, the generic payment request button is displayed. Even if the device supports a specific payment provider like Google Pay, Microsoft Wallet, Samsung Pay, etc., the generic button will first offer the cards saved within the webbrowser in addition to the other payment methods. Only upon selecting the specific payment provider like GPay, the corresponding payment flow is started.
- Right now, the rendering of the payment button is completely in the hands of Stripe. Once pretix takes on the task of doing this, we should try to detect if the browser supports well known payment methods like GPay in addition to the browser-saved cards. If that's the case, we should add the corresponding marks onto the "Pay Now"-Button (like [this](https://developers.google.com/pay/api/images/brand-guidelines/google-pay-mark.png), [this](https://assets.pcmag.com/media/images/490984-samsung-pay.png?width=1600&height=900), or [this](https://www.firstffcu.com/images/MS-Wallet_stacked_rgb_grey.png)), so the customer can identify the purpose of the button easier.

- [x] Also, all of this is still based against the pretix 1.x codebase ;-)
2018-08-15 09:22:31 +02:00
Raphael Michel
673a4e6805 Fix locale-dependent test 2018-08-14 18:48:13 +02:00
Raphael Michel
d017ccfbd4 Merge pull request #987 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-08-14 18:10:02 +02:00
Raphael Michel
1f52ed2e83 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2712 of 2712 strings)

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

powered by weblate
2018-08-14 16:08:34 +00:00
Raphael Michel
08e83f616c Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2712 of 2712 strings)

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

powered by weblate
2018-08-14 15:57:40 +00:00
Raphael Michel
51edc4652e Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2712 of 2712 strings)

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

powered by weblate
2018-08-14 15:51:19 +00:00
Raphael Michel
a3c6f38642 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2674 of 2674 strings)

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

powered by weblate
2018-08-14 15:13:17 +00:00
Raphael Michel
a1db53f50b Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-08-14 17:13:11 +02:00
Raphael Michel
9e1046fde3 Merge pull request #989 from pretix/approvals
Require approval for orders of specific products
2018-08-14 17:12:32 +02:00
Raphael Michel
17173f72e0 Fix incorrect sum calculation 2018-08-14 14:16:14 +02:00
Raphael Michel
f60a99c357 Tests 2018-08-14 11:31:41 +02:00
Raphael Michel
1d763f1bc9 Widget: Fix voucher code argument 2018-08-14 11:11:46 +02:00
Raphael Michel
248b94c296 Approvals 2018-08-14 10:46:55 +02:00
Raphael Michel
f52447ff58 Model field 2018-08-14 10:46:32 +02:00
Raphael Michel
0cbacbb959 Fix checking in something that is checked in multiple times 2018-08-14 08:56:53 +02:00
Raphael Michel
a01edecaef Fix incorrect test 2018-08-13 18:10:43 +02:00
Raphael Michel
779756f1ab API: Allow to delete order positions 2018-08-13 18:09:10 +02:00
Raphael Michel
723fedc066 Widget button: Fall back to front page if no items are specified 2018-08-13 14:35:13 +02:00
Raphael Michel
a83bb23540 Widget: Allow to disable voucher input 2018-08-13 14:31:57 +02:00
Raphael Michel
5d68a5133e Add pseudonymization_id filter to API 2018-08-13 08:55:57 +02:00
Raphael Michel
8ca629151d Order list exporter: Fix payment date and format localization 2018-08-12 19:52:20 +02:00
Raphael Michel
693965af28 Add signal html_page_start 2018-08-11 12:52:46 +02:00
Raphael Michel
e645a350f2 Stripe: Support for pretix.eu 2018-08-11 12:31:44 +02:00
Raphael Michel
85e9808550 Fix quirk in data-display-dependency 2018-08-11 10:48:48 +02:00
Raphael Michel
0ce1c4565e Merge pull request #986 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-08-11 10:20:55 +02:00
Raphael Michel
478964ad30 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (64 of 64 strings)

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

powered by weblate
2018-08-11 08:20:11 +00:00
Raphael Michel
74a04e3b35 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2674 of 2674 strings)

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

powered by weblate
2018-08-11 08:18:49 +00:00
Raphael Michel
a48992ed9d Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2674 of 2674 strings)

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

powered by weblate
2018-08-11 08:18:10 +00:00
Raphael Michel
9a6ea8c9bb Translated on translate.pretix.eu (German)
Currently translated at 100.0% (64 of 64 strings)

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

powered by weblate
2018-08-11 08:17:39 +00:00
Raphael Michel
51b05cb128 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-08-11 10:14:23 +02:00
Raphael Michel
de33d6d44c Check-in list PDF: Proper word wrapping 2018-08-10 16:43:27 +02:00
Raphael Michel
3d5cc98df5 Add option to require company name 2018-08-10 16:05:20 +02:00
Raphael Michel
13f3b54393 Refactor order overview and hide empty fees section 2018-08-09 18:04:58 +02:00
Raphael Michel
f17f7b2272 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-08-09 17:24:04 +02:00
Raphael Michel
f61dc7197a Widget: Reload information after closing the iFrame 2018-08-09 16:08:41 +02:00
Raphael Michel
0534508bc3 Widget: Redirect to front page if a cart already exists 2018-08-09 16:08:41 +02:00
Raphael Michel
446c7ffd6a Widget: Fix opening voucher redemption page in widget 2018-08-09 16:08:41 +02:00
Raphael Michel
79e6216669 Widget: Clear CTA in active-cart message 2018-08-09 16:08:41 +02:00
Raphael Michel
5047e48de5 Check-in list export: Default to question answers of parent 2018-08-08 16:59:46 +02:00
Raphael Michel
bd48112bf9 Refs #710 -- Remove monkeypatch for django-hijack 2018-08-08 09:24:52 +02:00
Raphael Michel
5dc100d900 Move dangerous order clause 2018-08-08 09:00:44 +02:00
Raphael Michel
9f2ecb67d4 Do not use copy to copy models 2018-08-07 16:53:09 +02:00
Raphael Michel
5e4f45826e Merge pull request #983 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-08-07 15:58:43 +02:00
Raphael Michel
be6ff21184 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2672 of 2672 strings)

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

powered by weblate
2018-08-07 13:58:10 +00:00
Raphael Michel
5c660fbe7f Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2672 of 2672 strings)

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

powered by weblate
2018-08-07 13:57:25 +00:00
Raphael Michel
108718f275 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (63 of 63 strings)

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

powered by weblate
2018-08-07 13:48:54 +00:00
Raphael Michel
ab53a0b403 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (63 of 63 strings)

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

powered by weblate
2018-08-07 13:48:04 +00:00
Raphael Michel
49b815bc98 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-08-07 15:45:52 +02:00
Raphael Michel
c702814203 Do not use deepcopy on models 2018-08-07 15:45:22 +02:00
Raphael Michel
0c0172a0b6 Fix inconsistent checks in order offsetting 2018-08-07 15:45:22 +02:00
Tobias Kunze
a8266c22f6 Make fields disabled if presale is over 2018-08-07 14:48:29 +02:00
Tobias Kunze
532c7fbc8f Add helpful title text to numeric inputs 2018-08-07 14:48:29 +02:00
Tobias Kunze
23ed381859 Center free price input for consistency
Looks better in Firefox, too
2018-08-07 14:48:29 +02:00
Raphael Michel
1ad11b0c58 Global Banner message: Only show Read more if appropriate 2018-08-07 12:39:14 +02:00
Raphael Michel
18cca916a0 Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-08-07 12:36:37 +02:00
Raphael Michel
97012082de Fix #972 -- Clarify sum in quota detail 2018-08-07 12:34:35 +02:00
Raphael Michel
423810cf61 Use a defaultdict for log rendering 2018-08-07 12:19:00 +02:00
Raphael Michel
a5159ce8e1 Do not show zeros in order overview 2018-08-07 11:37:14 +02:00
Raphael Michel
4dd3952c19 Fix waiting list tests 2018-08-07 11:36:19 +02:00
Raphael Michel
1e26b5c5f1 Add test case for working list priority 2018-08-07 10:56:48 +02:00
Raphael Michel
67897dfcc0 Fix #406 -- Allow moving waiting list entries to the top or bottom 2018-08-07 10:53:07 +02:00
Raphael Michel
0100604798 Allow to download waiting list 2018-08-07 10:38:26 +02:00
Raphael Michel
47afe01721 Improve waiting list filters 2018-08-07 10:28:37 +02:00
Raphael Michel
a2e12b795f Event settings: Fix custom widget 2018-08-07 10:09:34 +02:00
Raphael Michel
806ab3438e Fix rebuild command 2018-08-06 16:28:54 +02:00
Raphael Michel
f4be90fdd0 Fix overpaid queries 2018-08-06 16:16:19 +02:00
Raphael Michel
dd46767ee3 Merge pull request #981 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-08-06 16:12:52 +02:00
Raphael Michel
a2c712e5b3 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2656 of 2656 strings)

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

powered by weblate
2018-08-06 14:10:01 +00:00
Raphael Michel
35f3a0077a Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2656 of 2656 strings)

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

powered by weblate
2018-08-06 14:09:46 +00:00
Raphael Michel
bc4195942a Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2656 of 2656 strings)

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

powered by weblate
2018-08-06 13:19:08 +00:00
Raphael Michel
03baca2ed7 Translated on translate.pretix.eu (German)
Currently translated at 96.4% (2560 of 2656 strings)

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

powered by weblate
2018-08-06 12:28:50 +00:00
Raphael Michel
54a9c31a1a Fix setup.py management calls 2018-08-06 14:28:38 +02:00
Raphael Michel
db5073223d Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-08-06 12:49:09 +02:00
Raphael Michel
afd766999c Upgrade to Django 2.1 (#710)
* Upgrade to Django 2.0

* more models

* i18n foo

* Update setup.py

* Fix Sentry exception PRETIXEU-JC

* Enforce slug uniqueness

* Import sorting

* Upgrade to Django 2.1

* Travis config

* Try to fix PostgreSQL failure

* Smaller test matrix

* staticfiles→static

* Include request in all authenticate() calls
2018-08-06 12:48:46 +02:00
Raphael Michel
0637490216 Merge pull request #969 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-08-06 12:48:15 +02:00
TRIXHosting
6a3ba87b22 Translated on translate.pretix.eu (Spanish)
Currently translated at 3.3% (84 of 2563 strings)

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

powered by weblate
2018-08-06 10:24:41 +00:00
Muhammad Hewedy
20b287da52 Translated on translate.pretix.eu (Arabic)
Currently translated at 0.2% (5 of 2563 strings)

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

powered by weblate
2018-08-06 10:24:41 +00:00
Raphael Michel
18a378976b Fix #571 -- Partial payments and refunds 2018-08-06 12:24:36 +02:00
Raphael Michel
8e7af49206 Merge pull request #980 from johan12345/widget-default1
Widget: set default number to 1 if there is only one product
2018-08-05 16:38:47 +02:00
Raphael Michel
edeab082d4 Merge migration for compatibility with backport 2018-08-05 16:36:42 +02:00
Raphael Michel
7b76baaacf Backport a migration 2018-08-05 16:36:13 +02:00
Raphael Michel
053365cb67 Create a new migration for last commit 2018-08-05 16:32:18 +02:00
Raphael Michel
8301120a95 Delete old and unused settings entries 2018-08-05 16:27:30 +02:00
Raphael Michel
f15f0a6226 Update widget.js 2018-08-05 12:53:42 +02:00
Raphael Michel
0cfcadf5fa Fix test cases 2018-08-05 12:52:00 +02:00
Johan von Forstner
435c4acba6 Widget: set default number to 1 if there is only one product 2018-08-03 10:19:20 +02:00
Raphael Michel
edb913855d Add a CSS class to slug widgets 2018-07-29 15:39:31 +02:00
Raphael Michel
24739e1638 Hide waiting list vouchers in voucher list 2018-07-29 15:39:31 +02:00
Raphael Michel
54b906addb Force migration order 2018-07-23 15:04:21 +02:00
Raphael Michel
4a7a8df8a4 Small refactoring on ClassicInvoiceRenderer 2018-07-21 12:34:46 +02:00
Raphael Michel
f1dd62c936 Enable language tr 2018-07-20 11:07:43 +02:00
Raphael Michel
80cc7b0d64 Merge pull request #965 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-07-19 16:46:07 +02:00
Raphael Michel
eb4fbf3c0b Translated on translate.pretix.eu (French)
Currently translated at 94.3% (2416 of 2563 strings)

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

powered by weblate
2018-07-19 14:45:44 +00:00
Raphael Michel
c1cf1206fc Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (1464 of 1464 strings)

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

powered by weblate
2018-07-19 14:45:33 +00:00
Yunus Fırat Pişkin
efebc02d24 Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (1464 of 1464 strings)

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

powered by weblate
2018-07-19 14:10:45 +00:00
Raphael Michel
21dca8c17f Merge pull request #961 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-07-19 16:10:41 +02:00
Yunus Fırat Pişkin
4eb9839f77 Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (1464 of 1464 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Yunus Fırat Pişkin
3b7906ea04 Translated on translate.pretix.eu (Turkish)
Currently translated at 99.0% (1449 of 1464 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Yunus Fırat Pişkin
9d17858500 Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (62 of 62 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Yunus Fırat Pişkin
d5ceb5f465 Translated on translate.pretix.eu (Turkish)
Currently translated at 100.0% (62 of 62 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Yunus Fırat Pişkin
7dd2a0bbb4 Translated on translate.pretix.eu (Turkish)
Currently translated at 68.0% (996 of 1464 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Yunus Fırat Pişkin
13284fb3b9 Translated on translate.pretix.eu (Turkish)
Currently translated at 58.1% (36 of 62 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Yunus Fırat Pişkin
f42c5ec0ce Translated on translate.pretix.eu (Turkish)
Currently translated at 27.1% (397 of 1464 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Maarten van den Berg
6b269839cb Translated on translate.pretix.eu (Dutch)
Currently translated at 99.5% (2549 of 2563 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Raphael Michel
2eb3e0a278 Added translation on translate.pretix.eu (Turkish) 2018-07-19 08:07:53 +00:00
Raphael Michel
183a437678 Added translation on translate.pretix.eu (Turkish) 2018-07-19 08:07:53 +00:00
Claude
116b8171f8 Translated on translate.pretix.eu (French)
Currently translated at 94.3% (2416 of 2563 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Claude
c8c723bf4a Translated on translate.pretix.eu (French)
Currently translated at 100.0% (62 of 62 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Claude
d01cf018ce Translated on translate.pretix.eu (French)
Currently translated at 91.1% (2335 of 2563 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Claude
c701ab0776 Translated on translate.pretix.eu (French)
Currently translated at 100.0% (62 of 62 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Claude
180269d6b0 Translated on translate.pretix.eu (French)
Currently translated at 83.1% (2130 of 2563 strings)

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

powered by weblate
2018-07-19 08:07:53 +00:00
Raphael Michel
645c604fd4 Fix TypeError in price_too_high detection 2018-07-19 10:07:35 +02:00
Raphael Michel
de210db90d Fix error condition in event cloning 2018-07-19 09:44:53 +02:00
Raphael Michel
beddf1c772 Fix event meta deletion 2018-07-19 09:43:40 +02:00
Raphael Michel
75e618ee4a Throw cart error for price_too_high 2018-07-19 09:41:14 +02:00
Raphael Michel
d2a3ba182b Fix KeyError when accessing settings for disabled payment provider 2018-07-19 09:32:08 +02:00
Raphael Michel
427f78b14d OrderCreateSerializer: Do not crash on optional fields missing 2018-07-19 09:27:36 +02:00
Raphael Michel
febcf237ca Prevent a KeyError during form validation 2018-07-19 09:27:17 +02:00
Raphael Michel
5e158c3bd7 Prevent a KeyError with invalid add-on configuration 2018-07-19 09:27:17 +02:00
Raphael Michel
b4c9c86ba6 Prevent ValueError with invalid state of relative date 2018-07-19 09:27:17 +02:00
Raphael Michel
7c00853f5d Fix field that was accidentally required 2018-07-13 18:19:40 +02:00
Raphael Michel
a0fcb116f5 Bank transfer: Option to remove hyphen from reference 2018-07-13 16:39:55 +02:00
Raphael Michel
e46b33544d Fix race condition in formset validation 2018-07-11 14:57:31 +02:00
Raphael Michel
6b9c3ad4e7 PDF Layout: Make pretix logo a layout element, not a background element 2018-07-10 13:24:27 +02:00
Raphael Michel
dc12b9a197 Bump version to 2.0.0.dev0 2018-07-08 16:31:34 +02:00
Raphael Michel
d473f56c3a Bump version to 1.17.0 2018-07-08 16:26:39 +02:00
Raphael Michel
4138ab3d7d Merge pull request #960 from pretix-translations/weblate-pretix-pretix
Update from Weblate.
2018-07-08 16:07:15 +02:00
Raphael Michel
e18d1a451d Translated on translate.pretix.eu (Spanish)
Currently translated at 3.0% (76 of 2563 strings)

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

powered by weblate
2018-07-08 14:06:56 +00:00
Raphael Michel
a3048cd393 Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2563 of 2563 strings)

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

powered by weblate
2018-07-08 14:03:51 +00:00
Raphael Michel
dd8fdc6c0a Translated on translate.pretix.eu (German (informal))
Currently translated at 100.0% (2563 of 2563 strings)

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

powered by weblate
2018-07-08 14:03:05 +00:00
Raphael Michel
9099e4b709 Translated on translate.pretix.eu (German)
Currently translated at 100.0% (2563 of 2563 strings)

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

powered by weblate
2018-07-08 14:01:16 +00:00
Raphael Michel
52b176b9eb Update po files
[CI skip]

Signed-off-by: Raphael Michel <mail@raphaelmichel.de>
2018-07-08 15:49:10 +02:00
Raphael Michel
69fd70787c Fix a missing request parameter for a permissions check 2018-07-08 15:48:48 +02:00
Raphael Michel
ff37aea9c8 Update from Weblate. (#949) 2018-07-08 15:48:36 +02:00
Dimas 3r1ck Rivas
85f73977bf Translated on translate.pretix.eu (Spanish)
Currently translated at 2.9% (76 of 2542 strings)

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

powered by weblate
2018-06-25 10:53:51 +00:00
Pernille Thorsen
2c04ed48c2 Translated on translate.pretix.eu (Danish)
Currently translated at 65.9% (1676 of 2542 strings)

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

powered by weblate
2018-06-25 10:53:51 +00:00
Pernille Thorsen
1228754280 Translated on translate.pretix.eu (Danish)
Currently translated at 65.8% (1674 of 2542 strings)

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

powered by weblate
2018-06-25 10:53:51 +00:00
Raphael Michel
a43ee054ad Fix logging of file upload questions 2018-06-25 12:53:45 +02:00
Raphael Michel
83bc714739 Widget: Hide "FREE" if there is only one priced item 2018-06-25 12:53:45 +02:00
Raphael Michel
a08390c84a Use device width for width calculation of widget 2018-06-25 12:53:45 +02:00
Raphael Michel
8b6eacecfe Add X-Robots-Tag to redirect responses 2018-06-25 12:53:45 +02:00
Raphael Michel
fb96787697 Fix #765 -- Include P3P header 2018-06-25 12:53:45 +02:00
Raphael Michel
9cff77be62 Add blacklist to git hook recommendatio 2018-06-24 16:14:58 +02:00
Raphael Michel
0d1643da66 Add manual payments 2018-06-24 16:14:29 +02:00
Raphael Michel
5e7027647a Add bcc option for event emails 2018-06-22 13:28:54 +02:00
Raphael Michel
28f6f09e8f Upgrade py.test version 2018-06-19 18:19:59 +02:00
Raphael Michel
332af5d21b Fix #815 -- Add configurable minimum/maximum amount for payment methods 2018-06-19 18:00:33 +02:00
Tobias Kunze
e187005130 Strip [] in mail subject prefix (#950) 2018-06-19 12:46:08 +02:00
Raphael Michel
0357386f7c Hide some links when printing 2018-06-15 17:48:30 +02:00
Raphael Michel
47f8e5b8c6 API: FIll meta info 2018-06-15 12:04:40 +02:00
Raphael Michel
e95c9d73a1 Badges: Sort by last name 2018-06-14 16:23:55 +02:00
Raphael Michel
b7174070fe Check-in list export: Excel dialect 2018-06-14 16:19:05 +02:00
Raphael Michel
dd06a7b62c Sync setup.py with requirements.txt 2018-06-13 11:09:18 +02:00
Raphael Michel
ff9d480b6e Orders API: Improve validation errors 2018-06-13 11:08:54 +02:00
Raphael Michel
229ad9108b Fix ticket exporter 2018-06-12 15:50:03 +02:00
Raphael Michel
0e332d291a Fix locale of download reminder email 2018-06-11 15:32:08 +02:00
Raphael Michel
180904cdc2 Fix KeyError 2018-06-11 14:29:29 +02:00
Raphael Michel
0e83f7d807 Add documentation on cart endpoints 2018-06-11 14:29:22 +02:00
Raphael Michel
5d7931fcaf API: CartPositions (#948) 2018-06-11 13:18:37 +02:00
Raphael Michel
2e906b0bf5 Always inlude mail addresses in check-in list CSV 2018-06-10 15:21:18 +02:00
Raphael Michel
33ae6f12de Fix links in item descriptions 2018-06-10 15:11:19 +02:00
Raphael Michel
f302c2e154 Fix log entries from deleted plugins 2018-06-10 14:50:08 +02:00
Raphael Michel
3ee2492382 Bump version to 1.17.0.dev0 2018-06-07 18:04:26 +02:00
423 changed files with 75420 additions and 28910 deletions

View File

@@ -17,7 +17,7 @@ pypi:
- virtualenv env
- source env/bin/activate
- pip install -U pip wheel setuptools
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
- XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
- cd src
- python setup.py sdist
- pip install dist/pretix-*.tar.gz

View File

@@ -1,2 +1 @@
-r src/requirements/py34.txt
-r doc/requirements.txt

View File

@@ -15,13 +15,13 @@ if [ "$PRETIX_CONFIG_FILE" == "tests/travis_postgres.cfg" ]; then
fi
if [ "$1" == "style" ]; then
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt -r src/requirements/py34.txt
XDG_CACHE_HOME=/cache pip3 install -Ur src/requirements.txt -r src/requirements/dev.txt
cd src
flake8 .
isort -c -rc -df .
fi
if [ "$1" == "doctests" ]; then
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt -r src/requirements/py34.txt
XDG_CACHE_HOME=/cache pip3 install -Ur doc/requirements.txt
cd doc
make doctest
fi
@@ -39,21 +39,21 @@ if [ "$1" == "translation-spelling" ]; then
potypo
fi
if [ "$1" == "tests" ]; then
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt pytest-xdist
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt pytest-xdist
cd src
python manage.py check
make all compress
py.test --reruns 5 -n 2 tests
fi
if [ "$1" == "tests-cov" ]; then
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
cd src
python manage.py check
make all compress
coverage run -m py.test --reruns 5 tests && codecov
fi
if [ "$1" == "plugins" ]; then
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt -r src/requirements/py34.txt
pip3 install -r src/requirements.txt -Ur src/requirements/dev.txt
cd src
python setup.py develop
make all compress

View File

@@ -18,20 +18,10 @@ matrix:
env: JOB=tests-cov PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.6
env: JOB=style
- python: 3.4
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_sqlite.cfg
- python: 3.4
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_mysql.cfg
- python: 3.4
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.5
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.6
env: JOB=tests PRETIX_CONFIG_FILE=tests/travis_postgres.cfg
- python: 3.6

View File

@@ -53,6 +53,10 @@ Example::
A comma-separated list of plugins that are enabled by default for all new events.
Defaults to ``pretix.plugins.sendmail,pretix.plugins.statistics``.
``plugins_exclude``
A comma-separated list of plugins that are not available even though they are installed.
Defaults to an empty string.
``cookie_domain``
The cookie domain to be set. Defaults to ``None``.

View File

@@ -121,8 +121,7 @@ command if you're running PostgreSQL::
(venv)$ pip3 install "pretix[mysql]" gunicorn
If you are running Python 3.4, you also need to ``pip3 install typing``. This is not required on 3.5 or newer.
You can find out your Python version using ``python -V``.
Note that you need Python 3.5 or newer. You can find out your Python version using ``python -V``.
We also need to create a data directory::

9
doc/api/auth.rst Normal file
View File

@@ -0,0 +1,9 @@
Authentication
==============
.. toctree::
:maxdepth: 2
tokenauth
oauth
deviceauth

137
doc/api/deviceauth.rst Normal file
View File

@@ -0,0 +1,137 @@
.. _`rest-deviceauth`:
Device authentication
=====================
Initializing a new device
-------------------------
Users can create new devices in the "Device" section of their organizer settings. When creating
a new device, users can specify a list of events the device is allowed to access. After a new
device is created, users will be presented initialization instructions, consisting of an URL
and an initialization token. They will also be shown as a QR code with the following contents::
{"handshake_version": 1, "url": "https://pretix.eu", "token": "kpp4jn8g2ynzonp6"}
Your application should be able to scan a QR code of this type, or allow to enter the URL and the
initialization token manually. The handshake version is not used for manual initialization. When a
QR code is scanned with a higher handshake version than you support, you should reject the request
and prompt the user to update the client application.
After your application received the token, you need to call the initialization endpoint to obtain
a proper API token. At this point, you need to identify the name and version of your application,
as well as the type of underlying hardware. Example:
.. sourcecode:: http
POST /api/v1/device/initialize HTTP/1.1
Host: pretix.eu
Content-Type: application/json
{
"token": "kpp4jn8g2ynzonp6",
"hardware_brand": "Samsung",
"hardware_model": "Galaxy S",
"software_brand": "pretixdroid",
"software_version": "4.0.0"
}
Every initialization token can only be used once. On success, you will receive a response containing
information on your device as well as your API token:
.. sourcecode:: http
HTTP/1.1 200 OK
Content-Type: application/json
{
"organizer": "foo",
"device_id": 5,
"unique_serial": "HHZ9LW9JWP390VFZ",
"api_token": "1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd",
"name": "Bar"
}
Please make sure that you store this ``api_token`` value. We also recommend storing your device ID, your assigned
``unique_serial``, and the ``organizer`` you have access to, but that's up to you.
In case of an error, the response will look like this:
.. sourcecode:: http
HTTP/1.1 400 Bad Request
Content-Type: application/json
{"token":["This initialization token has already been used."]}
Performing API requests
-----------------------
You need to include the API token with every request to pretix' API in the ``Authorization`` header
like the following:
.. sourcecode:: http
:emphasize-lines: 3
GET /api/v1/organizers/ HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
Updating the software version
-----------------------------
If your application is updated, we ask you to tell the server about the new version in use. You can do this at the
following endpoint:
.. sourcecode:: http
POST /api/v1/device/update HTTP/1.1
Host: pretix.eu
Content-Type: application/json
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
{
"hardware_brand": "Samsung",
"hardware_model": "Galaxy S",
"software_brand": "pretixdroid",
"software_version": "4.1.0"
}
Creating a new API key
----------------------
If you think your API key might have leaked or just want to be extra cautious, the API allows you to create a new key.
The old API key will be invalid immediately. A request for a new key looks like this:
.. sourcecode:: http
POST /api/v1/device/roll HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
The response will look like the response to the initialization request.
Removing a device
-----------------
If you want implement a way to to deprovision a device in your software, you can call the ``revoke`` endpoint to
invalidate your API key. There is no way to reverse this operation.
.. sourcecode:: http
POST /api/v1/device/revoke HTTP/1.1
Host: pretix.eu
Authorization: Device 1kcsh572fonm3hawalrncam4l1gktr2rzx25a22l8g9hx108o9oi0rztpcvwnfnd
This can also be done by the user through the web interface.
Permissions
-----------
Device authentication is currently hardcoded to grant the following permissions:
* View event meta data and products etc.
* View and change orders
Devices cannot change events or products and cannot access vouchers.

View File

@@ -9,44 +9,20 @@ with pretix' REST API, such as authentication, pagination and similar definition
Authentication
--------------
If you're building an application for end users, we strongly recommend that you use our
:ref:`OAuth-based authentication progress <rest-oauth>`. However, for simpler needs, you
can also go with static API tokens that you can create on a per-team basis (see below).
To access the API, you need to present valid authentication credentials. pretix currently
supports the following authorization schemes:
You need to include the API token with every request to pretix' API in the ``Authorization`` header
like the following:
.. sourcecode:: http
:emphasize-lines: 3
GET /api/v1/organizers/ HTTP/1.1
Host: pretix.eu
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k
.. note:: The API currently also supports authentication via browser sessions, i.e. the
same way that you authenticate with pretix when using the browser interface.
Using this type of authentication is *not* officially supported for use by
third-party clients and might change or be removed at any time. We plan on
adding OAuth2 support in the future for user-level authentication. If you want
to use session authentication, be sure to comply with Django's `CSRF policies`_.
Obtaining an API token
----------------------
To authenticate your API requests, you need to obtain an API token. You can create a
token in the pretix web interface on the level of organizer teams. Create a new team
or choose an existing team that has the level of permissions the token should have and
create a new token using the form below the list of team members:
.. image:: img/token_form.png
:class: screenshot
You can enter a description for the token to distinguish from other tokens later on.
Once you click "Add", you will be provided with an API token in the success message.
Copy this token, as you won't be able to retrieve it again.
.. image:: img/token_success.png
:class: screenshot
* :ref:`rest-tokenauth`: This is the simplest way and recommended for server-side applications
that interact with pretix without user interaction.
* :ref:`rest-oauth`: This is the recommended way to use if you write a third-party application
that users can connect with their pretix account. It provides the best user experience, but
requires user interaction and slightly more implementation effort.
* :ref:`rest-deviceauth`: This is the recommended way if you build apps or hardware devices that can
connect to pretix, e.g. for processing check-ins or to sell tickets offline. It provides a way
to uniquely identify devices and allows for a quick configuration flow inside your software.
* Authentication using browser sessions: This is used by the pretix web interface and it is *not*
officially supported for use by third-party applications. It might change or be removed at any
time without prior notice. If you use it, you need to comply with Django's `CSRF policies`_.
Permissions
-----------
@@ -204,4 +180,4 @@ as the string values ``true`` and ``false``.
If the ``ordering`` parameter is documented for a resource, you can use it to sort the result set by one of the allowed
fields. Prepend a ``-`` to the field name to reverse the sort order.
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax
.. _CSRF policies: https://docs.djangoproject.com/en/1.11/ref/csrf/#ajax

View File

@@ -14,5 +14,5 @@ in functionality over time.
:maxdepth: 2
fundamentals
oauth
auth
resources/index

View File

@@ -1,7 +1,7 @@
.. _`rest-oauth`:
OAuth support / "Connect with pretix"
=====================================
OAuth authentication / "Connect with pretix"
============================================
In addition to static tokens, pretix supports `OAuth2`_-based authentication starting with
pretix 1.16. This allows you to put a "Connect with pretix" button into your website or tool
@@ -168,4 +168,4 @@ pretix user interface.
.. _OAuth2: https://en.wikipedia.org/wiki/OAuth
.. _OAuth2 Simplified: https://aaronparecki.com/oauth-2-simplified/
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication
.. _HTTP Basic authentication: https://en.wikipedia.org/wiki/Basic_access_authentication

258
doc/api/resources/carts.rst Normal file
View File

@@ -0,0 +1,258 @@
.. _rest-carts:
Cart positions
==============
The API provides limited access to the cart position data model. This API currently only allows creating and deleting
cart positions to reserve quota.
Cart position resource
----------------------
The cart position resource contains the following public fields:
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the cart position
cart_id string Identifier of the cart this belongs to. Needs to end
in "@api" for API-created positions.
datetime datetime Time of creation
expires datetime The cart position will expire at this time and no longer block quota
item integer ID of the item
variation integer ID of the variation (or ``null``)
price money (string) Price of this position
attendee_name string Specified attendee name for this position (or ``null``)
attendee_email string Specified attendee email address for this position (or ``null``)
voucher integer Internal ID of the voucher used for this position (or ``null``)
addon_to integer Internal ID of the position this position is an add-on for (or ``null``)
subevent integer ID of the date inside an event series this position belongs to (or ``null``).
answers list of objects Answers to user-defined questions
├ question integer Internal ID of the answered question
├ answer string Text representation of the answer
├ question_identifier string The question's ``identifier`` field
├ options list of integers Internal IDs of selected option(s)s (only for choice types)
└ option_identifiers list of strings The ``identifier`` fields of the selected option(s)s
===================================== ========================== =======================================================
.. versionchanged:: 1.17
This resource has been added.
Cart position endpoints
-----------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
Returns a list of API-created cart positions.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ 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
X-Page-Generated: 2017-12-01T10:00:00Z
{
"count": 1,
"next": null,
"previous": null,
"results": [
{
"id": 1,
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": null,
"attendee_email": null,
"voucher": null,
"addon_to": null,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"answers": []
}
]
}
:query integer page: The page number in case of a multi-page result set, default is 1
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
Returns information on one cart position, identified by its internal ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/cartpositions/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": 1,
"cart_id": "XwokV8FojQviD9jhtDzKvHFdlLRNMhlfo3cNjGbuK6MUTQDT@api",
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": null,
"attendee_email": null,
"voucher": null,
"addon_to": null,
"subevent": null,
"datetime": "2018-06-11T10:00:00Z",
"expires": "2018-06-11T10:00:00Z",
"includes_tax": true,
"answers": []
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the position to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested cart position does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/
Creates a new cart position.
.. warning:: This endpoint is considered **experimental**. It might change at any time without prior notice.
.. warning::
This endpoint is intended for advanced users. It is not designed to be used to build your own shop frontend.
There is a lot that it does not or can not do, and you will need to be careful using it.
It allows to bypass many of the restrictions imposed when creating a cart through the
regular shop.
Specifically, this endpoint currently
* does not validate if products are only to be sold in a specific time frame
* does not validate if the event's ticket sales are already over or haven't started
* does not support add-on products at the moment
* does not check or calculate 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 support file upload questions
You can supply the following fields of the resource:
* ``cart_id`` (optional, needs to end in ``@api``)
* ``item``
* ``variation`` (optional)
* ``price``
* ``attendee_name`` (optional)
* ``attendee_email`` (optional)
* ``subevent`` (optional)
* ``expires`` (optional)
* ``includes_tax`` (optional)
* ``answers``
* ``question``
* ``answer``
* ``options``
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/cartpositions/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
{
"item": 1,
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_email": null,
"answers": [
{
"question": 1,
"answer": "23",
"options": []
}
],
"subevent": null
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
(Full cart position resource, see above.)
:param organizer: The ``slug`` field of the organizer of the event to create a position for
:param event: The ``slug`` field of the event to create a position for
:statuscode 201: no error
:statuscode 400: The item could not be created due to invalid submitted data or lack of quota.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
order.
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/cartpositions/(id)/
Deletes a cart position, identified by its internal ID.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/cartpositions/1/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
**Example response**:
.. sourcecode:: http
HTTP/1.1 204 No Content
Vary: Accept
Content-Type: application/json
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the position to delete
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested cart position does not exist.

View File

@@ -332,6 +332,10 @@ Order position endpoints
The ``.../redeem/`` endpoint has been added.
.. versionchanged:: 2.0
The order positions endpoint has been extended by the filter queries ``voucher`` and ``voucher__code``.
.. 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
@@ -422,6 +426,8 @@ Order position endpoints
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
comma-separated IDs.
:query string voucher: Only return positions with a specific voucher.
:query string voucher__code: Only return positions with a specific voucher code.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param list: The ID of the check-in list to look for

View File

@@ -41,6 +41,10 @@ plugins list A list of packa
The ``plugins`` field has been added.
The operations POST, PATCH, PUT and DELETE have been added.
.. versionchanged:: 2.1
Filters have been added to the list of events.
Endpoints
---------
@@ -96,6 +100,12 @@ Endpoints
}
:query page: The page number in case of a multi-page result set, default is 1
:query is_public: If set to ``true``/``false``, only events with a matching value of ``is_public`` are returned.
:query live: If set to ``true``/``false``, only events with a matching value of ``live`` are returned.
:query has_subevents: If set to ``true``/``false``, only events with a matching value of ``has_subevents`` are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned. Event series are never (always) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned. Event series are never (always) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned. Event series are never returned.
:param organizer: The ``slug`` field of a valid organizer
:statuscode 200: no error
:statuscode 401: Authentication failure

View File

@@ -20,3 +20,4 @@ Resources and endpoints
vouchers
checkinlists
waitinglist
carts

View File

@@ -59,6 +59,9 @@ checkin_attention boolean If ``True``, th
a product is being scanned.
original_price money (string) An original price, shown for comparison, not used
for price calculations.
require_approval boolean If ``True``, orders with this product will need to be
approved by the event organizer before they can be
paid.
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.
Can be empty. Only writable during creation,
@@ -96,7 +99,11 @@ addons list of objects Definition of a
.. versionchanged:: 1.16
The field ``internal_name`` and ``original_price`` fields have been added.
The ``internal_name`` and ``original_price`` fields have been added.
.. versionchanged:: 2.0
The field ``require_approval`` has been added.
Notes
-----
@@ -160,6 +167,7 @@ Endpoints
"max_per_order": null,
"checkin_attention": false,
"has_variations": false,
"require_approval": false,
"variations": [
{
"value": {"en": "Student"},
@@ -244,6 +252,7 @@ Endpoints
"max_per_order": null,
"checkin_attention": false,
"has_variations": false,
"require_approval": false,
"variations": [
{
"value": {"en": "Student"},
@@ -308,6 +317,7 @@ Endpoints
"min_per_order": null,
"max_per_order": null,
"checkin_attention": false,
"require_approval": false,
"variations": [
{
"value": {"en": "Student"},
@@ -361,6 +371,7 @@ Endpoints
"max_per_order": null,
"checkin_attention": false,
"has_variations": true,
"require_approval": false,
"variations": [
{
"value": {"en": "Student"},
@@ -445,6 +456,7 @@ Endpoints
"max_per_order": null,
"checkin_attention": false,
"has_variations": true,
"require_approval": false,
"variations": [
{
"value": {"en": "Student"},

View File

@@ -32,8 +32,8 @@ email string The customer em
locale string The locale used for communication with this customer
datetime datetime Time of order creation
expires datetime The order will expire, if it is still pending by this time
payment_date date Date of payment receipt
payment_provider string Payment provider used for this order
payment_date date **DEPRECATED AND INACCURATE** Date of payment receipt
payment_provider string **DEPRECATED AND INACCURATE** Payment provider used for this order
total money (string) Total value of this order
comment string Internal comment on this order
checkin_attention boolean If ``True``, the check-in app should show a warning
@@ -74,6 +74,12 @@ downloads list of objects List of ticket
download options.
├ output string Ticket output provider (e.g. ``pdf``, ``passbook``)
└ url string Download URL
require_approval boolean If ``True`` and the order is pending, this order
needs approval by an organizer before it can
continue. If ``True`` and the order is canceled,
this order has been denied by the event organizer.
payments list of objects List of payment processes (see below)
refunds list of objects List of refund processes (see below)
last_modified datetime Last modification of this object
===================================== ========================== =======================================================
@@ -108,6 +114,12 @@ last_modified datetime Last modificati
The attributes ``order.last_modified`` as well as the corresponding filters to the resource have been added.
An endpoint for order creation as well as ``…/mark_refunded/`` has been added.
.. versionchanged:: 2.0
The ``order.payment_date`` and ``order.payment_provider`` attributes have been deprecated in favor of the new
nested ``payments`` and ``refunds`` resources, but will still be served and removed in 2.2. The ``require_approval``
attribute has been added, as have been the ``…/approve/`` and ``…/deny/`` endpoints.
.. _order-position-resource:
Order position resource
@@ -167,9 +179,53 @@ pdf_data object Data object req
The attributes ``pseudonymization_id`` and ``pdf_data`` have been added.
.. _order-payment-resource:
Order endpoints
---------------
Order payment resource
----------------------
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
local_id integer Internal ID of this payment, starts at 1 for every order
state string Payment state, one of ``created``, ``pending``, ``confirmed``, ``canceled``, ``pending``, ``failed``, or ``refunded``
amount money (string) Payment amount
created datetime Date and time of creation of this payment
payment_date datetime Date and time of completion of this payment (or ``null``)
provider string Identification string of the payment provider
===================================== ========================== =======================================================
.. versionchanged:: 2.0
This resource has been added.
.. _order-payment-resource:
Order refund resource
---------------------
.. rst-class:: rest-resource-table
===================================== ========================== =======================================================
Field Type Description
===================================== ========================== =======================================================
local_id integer Internal ID of this payment, starts at 1 for every order
state string Payment state, one of ``created``, ``transit``, ``external``, ``canceled``, ``failed``, or ``done``
source string How this refund has been created, one of ``buyer``, ``admin``, or ``external``
amount money (string) Payment amount
created datetime Date and time of creation of this payment
payment_date datetime Date and time of completion of this payment (or ``null``)
provider string Identification string of the payment provider
===================================== ========================== =======================================================
.. versionchanged:: 2.0
This resource has been added.
List of all orders
------------------
.. versionchanged:: 1.15
@@ -216,6 +272,7 @@ Order endpoints
"total": "23.00",
"comment": "",
"checkin_attention": false,
"require_approval": false,
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"is_business": True,
@@ -275,7 +332,18 @@ Order endpoints
"output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/"
}
]
],
"payments": [
{
"local_id": 1,
"state": "confirmed",
"amount": "23.00",
"created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z",
"provider": "banktransfer"
}
],
"refunds": []
}
]
}
@@ -285,6 +353,8 @@ Order endpoints
``status``. Default: ``datetime``
:query string code: Only return orders that match the given order code
:query string status: Only return orders in the given order status (see above)
:query boolean require_approval: If set to ``true`` or ``false``, only categories with this value for the field
``require_approval`` will be returned.
:query string email: Only return orders created with the given email address
:query string locale: Only return orders with the given customer locale
:query datetime modified_since: Only return orders that have changed since the given date
@@ -296,6 +366,9 @@ Order endpoints
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual orders
--------------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/
Returns information on one order, identified by its order code.
@@ -331,6 +404,7 @@ Order endpoints
"total": "23.00",
"comment": "",
"checkin_attention": false,
"require_approval": false,
"invoice_address": {
"last_modified": "2017-12-01T10:00:00Z",
"company": "Sample company",
@@ -390,7 +464,18 @@ Order endpoints
"output": "pdf",
"url": "https://pretix.eu/api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/download/pdf/"
}
]
],
"payments": [
{
"local_id": 1,
"state": "confirmed",
"amount": "23.00",
"created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z",
"provider": "banktransfer"
}
],
"refunds": []
}
:param organizer: The ``slug`` field of the organizer to fetch
@@ -401,6 +486,9 @@ Order endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
Order ticket download
---------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/download/(output)/
Download tickets for an order, identified by its order code. Depending on the chosen output, the response might
@@ -442,6 +530,9 @@ Order endpoints
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Creating orders
---------------
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/
Creates a new order.
@@ -487,18 +578,23 @@ Order endpoints
* ``code`` (optional)
* ``status`` (optional) Defaults to pending for non-free orders and paid for free orders. You can only set this to
``"n"`` for pending or ``"p"`` for paid. If you create a paid order, the ``order_paid`` signal will **not** be
sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and then call
the ``mark_paid`` API method.
``"n"`` for pending or ``"p"`` for paid. We will create a payment object for this order either in state ``created``
or in state ``confirmed``, depending on this value. If you create a paid order, the ``order_paid`` signal will
**not** be sent out to plugins and no email will be sent. If you want that behavior, create an unpaid order and
then call the ``mark_paid`` API method.
* ``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
creation.
* ``email``
* ``locale``
* ``payment_provider`` The identifier of the payment provider set for this order. This needs to be an existing
payment provider. You should use ``"free"`` for free orders.
* ``payment_info`` (optional) You can pass a nested JSON object that will be set as the internal ``payment_info``
value of the order. 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 provider will not be called
to do anything about this (i.e. if you pass a bank account to a debit provider, *no* charge will be created),
this is just informative in case you *handled the payment already*.
payment provider. You should use ``"free"`` for free orders, and we strongly advise to use ``"manual"`` 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``
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
provider will not be called to do anything about this (i.e. if you pass a bank account to a debit provider, *no*
charge will be created), this is just informative in case you *handled the payment already*.
* ``comment`` (optional)
* ``checkin_attention`` (optional)
* ``invoice_address`` (optional)
@@ -580,11 +676,11 @@ Order endpoints
{
"positionid": 1,
"item": 1,
"variation": None,
"variation": null,
"price": "23.00",
"attendee_name": "Peter",
"attendee_email": None,
"addon_to": None,
"attendee_email": null,
"addon_to": null,
"answers": [
{
"question": 1,
@@ -592,7 +688,7 @@ Order endpoints
"options": []
}
],
"subevent": None
"subevent": null
}
],
}
@@ -615,6 +711,9 @@ Order endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to create this
order.
Order state operations
----------------------
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/mark_paid/
Marks a pending or expired order as successfully paid.
@@ -849,9 +948,88 @@ Order endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/approve/
Order position endpoints
------------------------
Approve an order that is pending approval.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/approve/ 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
{
"code": "ABC12",
"status": "n",
"require_approval": false,
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param code: The ``code`` field of the order to modify
:statuscode 200: no error
:statuscode 400: The order cannot be approved, likely because the current order status does not allow it.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
:statuscode 409: The server was unable to acquire a lock and could not process your request. You can try again after a short waiting period.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/deny/
Marks an order that is pending approval as denied.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/deny/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: text/json
{
"send_email": true,
"comment": "You're not a business customer!"
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"code": "ABC12",
"status": "c",
"require_approval": true,
...
}
:param organizer: The ``slug`` field of the organizer to modify
:param event: The ``slug`` field of the event to modify
:param code: The ``code`` field of the order to modify
:statuscode 200: no error
:statuscode 400: The order cannot be marked as denied since the current order status does not allow it.
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
List of all order positions
---------------------------
.. versionchanged:: 1.15
@@ -859,6 +1037,11 @@ Order position endpoints
``order__status__in``, ``subevent__in``, ``addon_to__in`` and ``search``. The search for attendee names and order
codes is now case-insensitive.
.. versionchanged:: 2.0
The order positions endpoint has been extended by the filter queries ``voucher``, ``voucher__code`` and
``pseudonymization_id``.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/
Returns a list of all order positions within a given event.
@@ -940,6 +1123,7 @@ Order position endpoints
:query string attendee_name: Only return positions with the given value in the attendee_name field. Also, add-on
products positions are shown if they refer to an attendee with the given name.
:query string secret: Only return positions with the given ticket secret.
:query string pseudonymization_id: Only return positions with the given pseudonymization ID.
:query string order__status: Only return positions with the given order status.
:query string order__status__in: Only return positions with one the given comma-separated order status.
:query boolean has_checkin: If set to ``true`` or ``false``, only return positions that have or have not been
@@ -949,12 +1133,17 @@ Order position endpoints
:query integer addon_to: Only return positions that are add-ons to the position with the given ID.
:query integer addon_to__in: Only return positions that are add-ons to one of the positions with the given
comma-separated IDs.
:query string voucher: Only return positions with a specific voucher.
:query string voucher__code: Only return positions with a specific voucher code.
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
Fetching individual positions
-----------------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Returns information on one order position, identified by its internal ID.
@@ -1023,6 +1212,9 @@ Order position endpoints
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist.
Order position ticket download
------------------------------
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/download/(output)/
Download tickets for one order position, identified by its internal ID.
@@ -1064,3 +1256,507 @@ Order position endpoints
:statuscode 404: The requested order position or download provider does not exist.
:statuscode 409: The file is not yet ready and will now be prepared. Retry the request after waiting for a few
seconds.
Manipulating individual positions
---------------------------------
.. http:delete:: /api/v1/organizers/(organizer)/events/(event)/orderpositions/(id)/
Deletes an order position, identified by its internal ID.
**Example request**:
.. sourcecode:: http
DELETE /api/v1/organizers/bigevents/events/sampleconf/orderpositions/23442/ 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 fetch
:param event: The ``slug`` field of the event to fetch
:param id: The ``id`` field of the order position to delete
:statuscode 204: no error
:statuscode 400: This position cannot be deleted (e.g. last position in order)
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order position does not exist.
Order payment endpoints
-----------------------
.. versionchanged:: 2.0
These endpoints have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/
Returns a list of all payments for an order.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/ 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": [
{
"local_id": 1,
"state": "confirmed",
"amount": "23.00",
"created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z",
"provider": "banktransfer"
}
]
}
: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
:param event: The ``slug`` field of the event to fetch
:param order: The ``code`` field of the order to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/
Returns information on one payment, identified by its order-local ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/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
{
"local_id": 1,
"state": "confirmed",
"amount": "23.00",
"created": "2017-12-01T10:00:00Z",
"payment_date": "2017-12-04T12:13:12Z",
"provider": "banktransfer"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the payment to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or payment does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/confirm/
Marks a payment as confirmed. Only allowed in states ``pending`` and ``created``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/confirm/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{"force": false}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"local_id": 1,
"state": "confirmed",
...
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the payment to modify
:statuscode 200: no error
:statuscode 400: Invalid request or payment state
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or payment does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/cancel/
Marks a payment as canceled. Only allowed in states ``pending`` and ``created``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/cancel/ 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
{
"local_id": 1,
"state": "canceled",
...
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the payment to modify
:statuscode 200: no error
:statuscode 400: Invalid request or payment state
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or payment does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/payments/(local_id)/refund/
Create and execute a manual refund. Only available in ``confirmed`` state. Returns a refund resource, not
a payment resource!
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/payments/1/refund/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"amount": "23.00",
"mark_refunded": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"local_id": 1,
"source": "admin",
"state": "done",
...
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the payment to modify
:statuscode 200: no error
:statuscode 400: Invalid request, payment state, or operation not supported by the payment provider
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or payment does not exist.
Order refund endpoints
----------------------
.. versionchanged:: 2.0
These endpoints have been added.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/
Returns a list of all refunds for an order.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/ 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": [
{
"local_id": 1,
"state": "done",
"source": "admin",
"amount": "23.00",
"payment": 1,
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"provider": "banktransfer"
}
]
}
: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
:param event: The ``slug`` field of the event to fetch
:param order: The ``code`` field of the order to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:get:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/
Returns information on one refund, identified by its order-local ID.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/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
{
"local_id": 1,
"state": "done",
"source": "admin",
"amount": "23.00",
"payment": 1,
"created": "2017-12-01T10:00:00Z",
"execution_date": "2017-12-04T12:13:12Z",
"provider": "banktransfer"
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the refund to fetch
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or refund does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/
Creates a refund manually.
.. warning:: We recommend to only use this endpoint for refunds with payment provider ``manual``. This endpoint also
does not check for mismatching amounts etc. Be careful!
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{
"state": "created",
"source": "admin",
"amount": "23.00",
"payment": 1,
"execution_date": null,
"provider": "manual",
"mark_refunded": false
}
**Example response**:
.. sourcecode:: http
HTTP/1.1 201 Created
Vary: Accept
Content-Type: application/json
{
"local_id": 1,
"state": "created",
"source": "admin",
"amount": "23.00",
"payment": 1,
"created": "2017-12-01T10:00:00Z",
"execution_date": null,
"provider": "manual"
}
: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
:param event: The ``slug`` field of the event to fetch
:param order: The ``code`` field of the order to fetch
:statuscode 200: no error
:statuscode 400: Invalid data supplied
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/done/
Marks a refund as completed. Only allowed in states ``transit`` and ``created``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/done/ 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
{
"local_id": 1,
"state": "done",
....
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the refund to modify
:statuscode 200: no error
:statuscode 400: Invalid request or refund state
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or refund does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/process/
Acts on an external refund, either marks the order as refunded or pending. Only allowed in state ``external``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/done/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content-Type: application/json
{"mark_refunded": false}
**Example response**:
.. sourcecode:: http
HTTP/1.1 200 OK
Vary: Accept
Content-Type: application/json
{
"local_id": 1,
"state": "done",
....
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the refund to modify
:statuscode 200: no error
:statuscode 400: Invalid request or refund state
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or refund does not exist.
.. http:post:: /api/v1/organizers/(organizer)/events/(event)/orders/(code)/refunds/(local_id)/cancel/
Marks a refund as canceled. Only allowed in states ``transit``, ``external``, and ``created``.
**Example request**:
.. sourcecode:: http
POST /api/v1/organizers/bigevents/events/sampleconf/orders/ABC12/refunds/1/cancel/ 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
{
"local_id": 1,
"state": "canceled",
....
}
:param organizer: The ``slug`` field of the organizer to fetch
:param event: The ``slug`` field of the event to fetch
:param code: The ``code`` field of the order to fetch
:param local_id: The ``local_id`` field of the refund to modify
:statuscode 200: no error
:statuscode 400: Invalid request or refund state
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view this resource.
:statuscode 404: The requested order or refund does not exist.

View File

@@ -128,7 +128,7 @@ Endpoints
POST /api/v1/organizers/bigevents/events/sampleconf/questions/1/options/ HTTP/1.1
Host: pretix.eu
Accept: application/json, text/javascript
Content: application/json
Content-Type: application/json
{
"identifier": "LVETRWVU",

View File

@@ -17,6 +17,7 @@ Field Type Description
===================================== ========================== =======================================================
id integer Internal ID of the sub-event
name multi-lingual string The sub-event's full name
event string The slug of the parent event
active boolean If ``true``, the sub-event ticket shop is publicly
available.
date_from datetime The sub-event's start date
@@ -40,6 +41,10 @@ meta_data dict Values set for
The ``meta_data`` field has been added.
.. versionchanged:: 2.1
The ``event`` field has been added, together with filters on the list of dates and an organizer-level list.
Endpoints
---------
@@ -72,6 +77,7 @@ Endpoints
{
"id": 1,
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -92,6 +98,10 @@ Endpoints
}
:query page: The page number in case of a multi-page result set, default is 1
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event to fetch
:statuscode 200: no error
@@ -121,6 +131,7 @@ Endpoints
{
"id": 1,
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
@@ -144,3 +155,63 @@ Endpoints
:statuscode 200: no error
:statuscode 401: Authentication failure
:statuscode 403: The requested organizer/event does not exist **or** you have no permission to view it.
.. http:get:: /api/v1/organizers/(organizer)/subevents/
Returns a list of all sub-events of any event series you have access to within an organizer account.
**Example request**:
.. sourcecode:: http
GET /api/v1/organizers/bigevents/subevents/ 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": 1,
"name": {"en": "First Sample Conference"},
"event": "sampleconf",
"active": false,
"date_from": "2017-12-27T10:00:00Z",
"date_to": null,
"date_admission": null,
"presale_start": null,
"presale_end": null,
"location": null,
"item_price_overrides": [
{
"item": 2,
"price": "12.00"
}
],
"variation_price_overrides": [],
"meta_data": {}
}
]
}
:query page: The page number in case of a multi-page result set, default is 1
:query active: If set to ``true``/``false``, only events with a matching value of ``active`` are returned.
:query event__live: If set to ``true``/``false``, only events with a matching value of ``live`` on the parent event are returned.
:query is_future: If set to ``true`` (``false``), only events that happen currently or in the future are (not) returned.
:query is_past: If set to ``true`` (``false``), only events that are over are (not) returned.
:query ends_after: If set to a date and time, only events that happen during of after the given time are returned.
:param organizer: The ``slug`` field of a valid organizer
:param event: The ``slug`` field of the event 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 it.

36
doc/api/tokenauth.rst Normal file
View File

@@ -0,0 +1,36 @@
.. _`rest-tokenauth`:
Token-based authentication
==========================
Obtaining an API token
----------------------
To authenticate your API requests with Tokens, you need to obtain a team-level API token.
You can create a token in the pretix web interface on the level of organizer teams. Create
a new team or choose an existing team that has the level of permissions the token should
have and create a new token using the form below the list of team members:
.. image:: img/token_form.png
:class: screenshot
You can enter a description for the token to distinguish from other tokens later on.
Once you click "Add", you will be provided with an API token in the success message.
Copy this token, as you won't be able to retrieve it again.
.. image:: img/token_success.png
:class: screenshot
Using an API token
------------------
You need to include the API token with every request to pretix' API in the ``Authorization`` header
like the following:
.. sourcecode:: http
:emphasize-lines: 3
GET /api/v1/organizers/ HTTP/1.1
Host: pretix.eu
Authorization: Token e1l6gq2ye72thbwkacj7jbri7a7tvxe614ojv8ybureain92ocub46t5gab5966k

View File

@@ -64,7 +64,7 @@ Similarly, there is ``organizer_permission_required`` and ``OrganizerPermissionR
event-related views, there is also a signal that allows you to add the view to the event navigation like this::
from django.core.urlresolvers import resolve, reverse
from django.urls import resolve, reverse
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from pretix.control.signals import nav_event

View File

@@ -0,0 +1,109 @@
.. highlight:: python
:linenothreshold: 5
Writing an HTML e-mail renderer plugin
======================================
An email renderer class controls how the HTML part of e-mails sent by pretix is built.
The creation of such a plugin is very similar to creating an export output.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
Output registration
-------------------
The email HTML renderer API does not make a lot of usage from signals, however, it
does use a signal to get a list of all available email renderers. Your plugin
should listen for this signal and return the subclass of ``pretix.base.email.BaseHTMLMailRenderer``
that we'll provide in this plugin::
from django.dispatch import receiver
from pretix.base.signals import register_html_mail_renderers
@receiver(register_html_mail_renderers, dispatch_uid="renderer_custom")
def register_mail_renderers(sender, **kwargs):
from .email import MyMailRenderer
return MyMailRenderer
The renderer class
------------------
.. class:: pretix.base.email.BaseHTMLMailRenderer
The central object of each email renderer is the subclass of ``BaseHTMLMailRenderer``.
.. py:attribute:: BaseHTMLMailRenderer.event
The default constructor sets this property to the event we are currently
working for.
.. autoattribute:: identifier
This is an abstract attribute, you **must** override this!
.. autoattribute:: verbose_name
This is an abstract attribute, you **must** override this!
.. autoattribute:: thumbnail_filename
This is an abstract attribute, you **must** override this!
.. autoattribute:: is_available
.. automethod:: render
This is an abstract method, you **must** implement this!
Helper class for template-base renderers
----------------------------------------
The email renderer that ships with pretix is based on Django templates to generate HTML.
In case you also want to render emails based on a template, we provided a ready-made base
class ``TemplateBasedMailRenderer`` that you can re-use to perform the following steps:
* Convert the body text and the signature to HTML using our markdown renderer
* Render the template
* Call `inlinestyler`_ to convert all ``<style>`` style sheets to inline ``style=""``
attributes for better compatibility
To use it, you just need to implement some variables::
class ClassicMailRenderer(TemplateBasedMailRenderer):
verbose_name = _('pretix default')
identifier = 'classic'
thumbnail_filename = 'pretixbase/email/thumb.png'
template_name = 'pretixbase/email/plainwrapper.html'
The template is passed the following context variables:
``site``
Name of the pretix installation (``settings.PRETIX_INSTANCE_NAME``)
``site_url``
Root URL of the pretix installation (``settings.SITE_URL``)
``body``
The body as markdown (render with ``{{ body|safe }}``)
``subject``
The email subject
``color``
The primary color of the event
``event``
The ``Event`` object
``signature`` (optional, only if configured)
The body as markdown (render with ``{{ signature|safe }}``)
``order`` (optional, only if applicable)
The ``Order`` object
.. _inlinestyler: https://pypi.org/project/inlinestyler/

View File

@@ -48,7 +48,8 @@ Backend
-------
.. automodule:: pretix.control.signals
:members: nav_event, html_head, quota_detail_html, nav_topbar, nav_global, nav_organizer, nav_event_settings, order_info, event_settings_widget, oauth_application_registered
: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
.. automodule:: pretix.base.signals

View File

@@ -10,6 +10,8 @@ Contents:
exporter
ticketoutput
payment
payment_2.0
email
invoice
shredder
customview

View File

@@ -9,6 +9,10 @@ is very similar to creating an export output.
Please read :ref:`Creating a plugin <pluginsetup>` first, if you haven't already.
.. warning:: We changed our payment provider API a lot in pretix 2.x. Our documentation page on :ref:`payment2.0`
might be insightful even if you do not have a payment provider to port, as it outlines the rationale
behind the current design.
Provider registration
---------------------
@@ -31,7 +35,7 @@ that the plugin will provide::
The provider class
------------------
.. class:: pretix.base.payment.BasePaymentProvider
.. py:class:: pretix.base.payment.BasePaymentProvider
The central object of each payment provider is the subclass of ``BasePaymentProvider``.
@@ -54,58 +58,60 @@ The provider class
This is an abstract attribute, you **must** override this!
.. autoattribute:: is_enabled
.. autoattribute:: public_name
.. automethod:: calculate_fee
.. autoattribute:: is_enabled
.. autoattribute:: settings_form_fields
.. automethod:: settings_content_render
.. automethod:: render_invoice_text
.. automethod:: is_allowed
.. automethod:: payment_form_render
.. automethod:: payment_form
.. automethod:: is_allowed
.. autoattribute:: payment_form_fields
.. automethod:: checkout_prepare
.. automethod:: payment_is_valid_session
.. automethod:: checkout_prepare
.. automethod:: checkout_confirm_render
This is an abstract method, you **must** override this!
.. automethod:: payment_perform
.. automethod:: execute_payment
.. automethod:: calculate_fee
.. automethod:: order_pending_mail_render
.. automethod:: order_pending_render
.. automethod:: payment_pending_render
This is an abstract method, you **must** override this!
.. autoattribute:: abort_pending_allowed
.. automethod:: render_invoice_text
.. automethod:: order_change_allowed
.. automethod:: order_can_retry
.. automethod:: payment_prepare
.. automethod:: order_prepare
.. automethod:: payment_control_render
.. automethod:: order_paid_render
.. automethod:: payment_refund_supported
.. automethod:: order_control_render
.. automethod:: payment_partial_refund_supported
.. automethod:: order_control_refund_render
.. automethod:: order_control_refund_perform
.. automethod:: is_implicit
.. automethod:: execute_refund
.. automethod:: shred_payment_info
.. autoattribute:: is_implicit
.. autoattribute:: is_meta
Additional views
----------------

View File

@@ -0,0 +1,129 @@
.. highlight:: python
:linenothreshold: 5
.. _`payment2.0`:
Porting a payment provider from pretix 1.x to pretix 2.x
========================================================
In pretix 2.x, we changed large parts of the payment provider API. This documentation details the changes we made
and shows you how you can make an existing pretix 1.x payment provider compatible with pretix 2.x
Conceptual overview
-------------------
In pretix 1.x, an order was always directly connected to a payment provider for the full life of an order. As long as
an order was unpaid, this could still be changed in some cases, but once an order was paid, no changes to the payment
provider were possible any more. Additionally, the internal state of orders allowed orders only to be fully paid or
not paid at all. This leads to a couple of consequences:
* Payment-related functions (like "execute payment" or "do a refund") always operated on full orders.
* Changing the total of an order was basically impossible once an order was paid, since there was no concept of
partial payments or partial refunds.
* Payment provider plugins needed to take complicated steps to detect cases that require human intervention, like e.g.
* An order has expired, no quota is left to revive it, but a payment has been received
* A payment has been received for a canceled order
* A payment has been received for an order that has already been paid with a different payment method
* An external payment service notified us of a refund/dispute
We noticed that we copied and repeated large portions of code in all our official payment provider plugins, just
to deal with some of these cases.
* Sometimes, there is the need to mark an order as refunded within pretix, without automatically triggering a refund
with an external API. Every payment method needed to implement a user interface for this independently.
* If a refund was not possible automatically, there was no way user to track which payments actually have been refunded
manually and which are still left to do.
* When the payment with one payment provider failed and the user changed to a different payment provider, all
information about the first payment was lost from the order object and could only be retrieved from order log data,
which also made it hard to design a data shredder API to get rid of this data.
In pretix 2.x, we introduced two new models, :py:class:`OrderPayment <pretix.base.models.OrderPayment>` and
:py:class:`OrderRefund <pretix.base.models.OrderRefund>`. Each instance of these is connected to an order and
represents one single attempt to pay or refund a specific amount of money. Each one of these has an individual state,
can individually fail or succeed, and carries an amount variable that can differ from the order total.
This has the following advantages:
* The system can now detect orders that are over- or underpaid, independent of the payment providers in use.
* Therefore, we can now allow partial payments, partial refunds, and changing paid orders, and automatically detect
the cases listed above and notify the user.
Payment providers now interact with those payment and refund objects more than with orders.
Your to-do list
---------------
Payment processing
""""""""""""""""""
* The method ``BasePaymentProvider.order_pending_render`` has been removed and replaced by a new
``BasePaymentProvider.payment_pending_render(request, payment)`` method that is passed an ``OrderPayment``
object instead of an ``Order``.
* The method ``BasePaymentProvider.payment_form_render`` now receives a new ``total`` parameter.
* The method ``BasePaymentProvider.payment_perform`` has been removed and replaced by a new method
``BasePaymentProvider.execute_payment(request, payment)`` that is passed an ``OrderPayment``
object instead of an ``Order``.
* The function ``pretix.base.services.mark_order_paid`` has been removed, instead call ``payment.confirm()``
on a pending ``OrderPayment`` object. If no further payments are required for this order, this will also
mark the order as paid automatically. Note that ``payment.confirm()`` can still throw a ``QuotaExceededException``,
however it will still mark the payment as complete (not the order!), so you should catch this exception and
inform the user, but not abort the transaction.
* A new property ``BasePaymentProvider.abort_pending_allowed`` has been introduced. Only if set, the user will
be able to retry a payment or switch the payment method when the order currently has a payment object in
state ``"pending"``. This replaces ``BasePaymentProvider.order_can_retry``, which no longer exists.
* The methods ``BasePaymentProvider.retry_prepare`` and ``BasePaymentProvider.order_prepare`` have both been
replaced by a new method ``BasePaymentProvider.payment_prepare(request, payment)`` that is passed an ``OrderPayment``
object instead of an ``Order``. **Keep in mind that this payment object might have an amount property that
differs from the order total, if the order is already partially paid.**
* The method ``BasePaymentProvider.order_paid_render`` has been removed.
* The method ``BasePaymentProvider.order_control_render`` has been removed and replaced by a new method
``BasePaymentProvider.payment_control_render(request, payment)`` that is passed an ``OrderPayment``
object instead of an ``Order``.
* There's no need to manually deal with excess payments or duplicate payments anymore, just setting the ``OrderPayment``
methods to the correct state will do the job.
Creating refunds
""""""""""""""""
* The methods ``BasePaymentProvider.order_control_refund_render`` and ``BasePaymentProvider.order_control_refund_perform``
have been removed.
* Two new boolean methods ``BasePaymentProvider.payment_refund_supported(payment)`` and ``BasePaymentProvider.payment_partial_refund_supported(payment)``
have been introduced. They should be set to return ``True`` if and only if the payment API allows to *automatically*
transfer the money back to the customer.
* A new method ``BasePaymentProvider.execute_refund(refund)`` has been introduced. This method is called using a
``OrderRefund`` object in ``"created"`` state and is expected to transfer the money back and confirm success with
calling ``refund.done()``. This will only ever be called if either ``BasePaymentProvider.payment_refund_supported(payment)``
or ``BasePaymentProvider.payment_partial_refund_supported(payment)`` return ``True``.
Processing external refunds
"""""""""""""""""""""""""""
* If e.g. a webhook API notifies you that a payment has been disputed or refunded with the external API, you are
expected to call ``OrderPayment.create_external_refund(self, amount, execution_date, info='{}')`` on this payment.
This will create and return an appropriate ``OrderRefund`` object and send out a notification. However, it will not
mark the order as refunded, but will ask the event organizer for a decision.
Data shredders
""""""""""""""
* The method ``BasePaymentProvider.shred_payment_info`` is no longer passed an order, but instead **either**
an ``OrderPayment`` **or** an ``OrderRefund``.

View File

@@ -86,6 +86,15 @@ Carts and Orders
.. autoclass:: pretix.base.models.OrderPosition
:members:
.. autoclass:: pretix.base.models.OrderFee
:members:
.. autoclass:: pretix.base.models.OrderPayment
:members:
.. autoclass:: pretix.base.models.OrderRefund
:members:
.. autoclass:: pretix.base.models.CartPosition
:members:

View File

@@ -18,7 +18,7 @@ External Dependencies
---------------------
Your should install the following on your system:
* Python 3.4 or newer
* Python 3.5 or newer
* ``pip`` for Python 3 (Debian package: ``python3-pip``)
* ``python-dev`` for Python 3 (Debian package: ``python3-dev``)
* ``libffi`` (Debian package: ``libffi-dev``)
@@ -54,10 +54,6 @@ The first thing you need are all the main application's dependencies::
cd src/
pip3 install -r requirements.txt -r requirements/dev.txt
If you are working with Python 3.4, you will also need (you can skip this for Python 3.5+)::
pip3 install -r requirements/py34.txt
Next, you need to copy the SCSS files from the source folder to the STATIC_ROOT directory::
python manage.py collectstatic --noinput
@@ -122,13 +118,15 @@ for example, to check for any errors in any staged files when committing::
export GIT_WORK_TREE=../
export GIT_DIR=../.git
source ../env/bin/activate # Adjust to however you activate your virtual environment
for file in $(git diff --cached --name-only | grep -E '\.py$')
for file in $(git diff --cached --name-only | grep -E '\.py$' | grep -Ev "migrations|mt940\.py|pretix/settings\.py|make_testdata\.py|testutils/settings\.py|tests/settings\.py|pretix/base/models/__init__\.py")
do
echo $file
git show ":$file" | flake8 - --stdin-display-name="$file" || exit 1 # we only want to lint the staged changes, not any un-staged changes
git show ":$file" | isort -df --check-only - | grep ERROR && exit 1 || true
done
This keeps you from accidentally creating commits violating the style guide.
Working with mails

View File

@@ -23,6 +23,7 @@ cronjob
cryptographic
debian
deduplication
deprovision
discoverable
django
dockerfile

View File

@@ -107,6 +107,13 @@ voucher's settings.
</div>
</noscript>
Disabling the voucher input
---------------------------
If you want to disable voucher input in the widget, you can pass the ``disable-vouchers`` attribute::
<pretix-widget event="https://pretix.eu/demo/democon/" disable-vouchers></pretix-widget>
pretix Button
-------------
@@ -136,7 +143,7 @@ resources. Then, instead of the ``pretix-widget`` tag, use the ``pretix-button``
As you can see, the ``pretix-button`` element takes an additional ``items`` attribute that specifies the items that
should be added to the cart. The syntax of this attribute is ``item_ITEMID=1,item_ITEMID=2,variation_ITEMID_VARID=4``
where ``ITEMID`` are the internal IDs of items to be added and ``VARID`` are the internal IDs of variations of those
items, if the items have variations.
items, if the items have variations. If you omit the ``items`` attribute, the general start page will be presented.
Just as the widget, the button supports the optional attributes ``voucher`` and ``skip-ssl-check``.

6
readthedocs.yml Normal file
View File

@@ -0,0 +1,6 @@
build:
image: latest
python:
version: 3.6

View File

@@ -8,6 +8,8 @@ recursive-include pretix/control/templates *
recursive-include pretix/presale/templates *
recursive-include pretix/plugins/banktransfer/templates *
recursive-include pretix/plugins/banktransfer/static *
recursive-include pretix/plugins/manualpayment/templates *
recursive-include pretix/plugins/manualpayment/static *
recursive-include pretix/plugins/paypal/templates *
recursive-include pretix/plugins/pretixdroid/templates *
recursive-include pretix/plugins/pretixdroid/static *

View File

@@ -1 +1 @@
__version__ = "1.16.0"
__version__ = "2.1.0"

View File

@@ -0,0 +1,25 @@
from django.contrib.auth.models import AnonymousUser
from rest_framework import exceptions
from rest_framework.authentication import TokenAuthentication
from pretix.base.models import Device
class DeviceTokenAuthentication(TokenAuthentication):
model = Device
keyword = 'Device'
def authenticate_credentials(self, key):
model = self.get_model()
try:
device = model.objects.select_related('organizer').get(api_token=key)
except model.DoesNotExist:
raise exceptions.AuthenticationFailed('Invalid token.')
if not device.initialized:
raise exceptions.AuthenticationFailed('Device has not been initialized.')
if not device.api_token:
raise exceptions.AuthenticationFailed('Device access has been revoked.')
return AnonymousUser(), device

View File

@@ -1,7 +1,7 @@
from rest_framework.permissions import SAFE_METHODS, BasePermission
from pretix.api.models import OAuthAccessToken
from pretix.base.models import Event
from pretix.base.models import Device, Event
from pretix.base.models.organizer import Organizer, TeamAPIToken
from pretix.helpers.security import (
SessionInvalid, SessionReauthRequired, assert_session_valid,
@@ -9,10 +9,9 @@ from pretix.helpers.security import (
class EventPermission(BasePermission):
model = TeamAPIToken
def has_permission(self, request, view):
if not request.user.is_authenticated and not isinstance(request.auth, TeamAPIToken):
if not request.user.is_authenticated and not isinstance(request.auth, (Device, TeamAPIToken)):
return False
if request.method not in SAFE_METHODS and hasattr(view, 'write_permission'):
@@ -31,7 +30,7 @@ class EventPermission(BasePermission):
except SessionReauthRequired:
return False
perm_holder = (request.auth if isinstance(request.auth, TeamAPIToken)
perm_holder = (request.auth if isinstance(request.auth, (Device, TeamAPIToken))
else request.user)
if 'event' in request.resolver_match.kwargs and 'organizer' in request.resolver_match.kwargs:
request.event = Event.objects.filter(
@@ -76,7 +75,7 @@ class EventCRUDPermission(EventPermission):
return False
elif view.action == 'destroy' and 'can_change_event_settings' not in request.eventpermset:
return False
elif view.action in ['retrieve', 'update', 'partial_update'] \
elif view.action in ['update', 'partial_update'] \
and 'can_change_event_settings' not in request.eventpermset:
return False

View File

@@ -46,7 +46,7 @@ class Migration(migrations.Migration):
('updated', models.DateTimeField(auto_now=True)),
('name', models.CharField(max_length=255, verbose_name='Application name')),
('redirect_uris', models.TextField(help_text='Allowed URIs list, space separated',
validators=[oauth2_provider.validators.validate_uris],
validators=[oauth2_provider.validators.URIValidator],
verbose_name='Redirection URIs')),
('client_id',
models.CharField(db_index=True, default=oauth2_provider.generators.generate_client_id, max_length=100,

View File

@@ -11,13 +11,13 @@ from oauth2_provider.models import (
AbstractAccessToken, AbstractApplication, AbstractGrant,
AbstractRefreshToken,
)
from oauth2_provider.validators import validate_uris
from oauth2_provider.validators import URIValidator
class OAuthApplication(AbstractApplication):
name = models.CharField(verbose_name=_("Application name"), max_length=255, blank=False)
redirect_uris = models.TextField(
blank=False, validators=[validate_uris],
blank=False, validators=[URIValidator],
verbose_name=_("Redirection URIs"),
help_text=_("Allowed URIs list, space separated")
)

View File

@@ -0,0 +1,121 @@
from datetime import timedelta
from django.utils.crypto import get_random_string
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.api.serializers.order import (
AnswerCreateSerializer, AnswerSerializer,
)
from pretix.base.models import Quota
from pretix.base.models.orders import CartPosition
class CartPositionSerializer(I18nAwareModelSerializer):
answers = AnswerSerializer(many=True)
class Meta:
model = CartPosition
fields = ('id', 'cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'voucher', 'addon_to', 'subevent', 'datetime', 'expires', 'includes_tax',
'answers',)
class CartPositionCreateSerializer(I18nAwareModelSerializer):
answers = AnswerCreateSerializer(many=True, required=False)
expires = serializers.DateTimeField(required=False)
class Meta:
model = CartPosition
fields = ('cart_id', 'item', 'variation', 'price', 'attendee_name', 'attendee_email',
'subevent', 'expires', 'includes_tax', 'answers',)
def create(self, validated_data):
answers_data = validated_data.pop('answers')
if not validated_data.get('cart_id'):
cid = "{}@api".format(get_random_string(48))
while CartPosition.objects.filter(cart_id=cid).exists():
cid = "{}@api".format(get_random_string(48))
validated_data['cart_id'] = cid
if not validated_data.get('expires'):
validated_data['expires'] = now() + timedelta(
minutes=self.context['event'].settings.get('reservation_time', as_type=int)
)
with self.context['event'].lock():
new_quotas = (validated_data.get('variation').quotas.filter(subevent=validated_data.get('subevent'))
if validated_data.get('variation')
else validated_data.get('item').quotas.filter(subevent=validated_data.get('subevent')))
if len(new_quotas) == 0:
raise ValidationError(
ugettext_lazy('The product "{}" is not assigned to a quota.').format(
str(validated_data.get('item'))
)
)
for quota in new_quotas:
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < 1):
raise ValidationError(
ugettext_lazy('There is not enough quota available on quota "{}" to perform '
'the operation.').format(
quota.name
)
)
cp = CartPosition.objects.create(event=self.context['event'], **validated_data)
for answ_data in answers_data:
options = answ_data.pop('options')
answ = cp.answers.create(**answ_data)
answ.options.add(*options)
return cp
def validate_cart_id(self, cid):
if cid and not cid.endswith('@api'):
raise ValidationError('Cart ID should end in @api or be empty.')
def validate_item(self, item):
if item.event != self.context['event']:
raise ValidationError(
'The specified item does not belong to this event.'
)
if not item.active:
raise ValidationError(
'The specified item is not active.'
)
return item
def validate_subevent(self, subevent):
if self.context['event'].has_subevents:
if not subevent:
raise ValidationError(
'You need to set a subevent.'
)
if subevent.event != self.context['event']:
raise ValidationError(
'The specified subevent does not belong to this event.'
)
elif subevent:
raise ValidationError(
'You cannot set a subevent for this event.'
)
return subevent
def validate(self, data):
if data.get('item'):
if data.get('item').has_variations:
if not data.get('variation'):
raise ValidationError('You should specify a variation for this item.')
else:
if data.get('variation').item != data.get('item'):
raise ValidationError(
'The specified variation does not belong to the specified item.'
)
elif data.get('variation'):
raise ValidationError(
'You cannot specify a variation for this item.'
)
return data

View File

@@ -4,6 +4,7 @@ from django.utils.functional import cached_property
from django.utils.translation import ugettext as _
from django_countries.serializers import CountryFieldMixin
from rest_framework.fields import Field
from rest_framework.relations import SlugRelatedField
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import Event, TaxRule
@@ -190,12 +191,13 @@ class SubEventItemVariationSerializer(I18nAwareModelSerializer):
class SubEventSerializer(I18nAwareModelSerializer):
item_price_overrides = SubEventItemSerializer(source='subeventitem_set', many=True)
variation_price_overrides = SubEventItemVariationSerializer(source='subeventitemvariation_set', many=True)
event = SlugRelatedField(slug_field='slug', read_only=True)
meta_data = MetaDataField(source='*')
class Meta:
model = SubEvent
fields = ('id', 'name', 'date_from', 'date_to', 'active', 'date_admission',
'presale_start', 'presale_end', 'location',
'presale_start', 'presale_end', 'location', 'event',
'item_price_overrides', 'variation_price_overrides', 'meta_data')

View File

@@ -79,7 +79,7 @@ class ItemSerializer(I18nAwareModelSerializer):
'position', 'picture', 'available_from', 'available_until',
'require_voucher', 'hide_without_voucher', 'allow_cancel',
'min_per_order', 'max_per_order', 'checkin_attention', 'has_variations',
'variations', 'addons', 'original_price')
'variations', 'addons', 'original_price', 'require_approval')
read_only_fields = ('has_variations', 'picture')
def get_serializer_context(self):

View File

@@ -3,17 +3,21 @@ from collections import Counter
from decimal import Decimal
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy
from django_countries.fields import Country
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.relations import SlugRelatedField
from rest_framework.reverse import reverse
from pretix.api.serializers.i18n import I18nAwareModelSerializer
from pretix.base.models import (
Checkin, Invoice, InvoiceAddress, InvoiceLine, Order, OrderPosition,
Question, QuestionAnswer, Quota,
Question, QuestionAnswer,
)
from pretix.base.models.orders import (
CartPosition, OrderFee, OrderPayment, OrderRefund,
)
from pretix.base.models.orders import OrderFee
from pretix.base.pdf import get_variables
from pretix.base.signals import register_ticket_outputs
@@ -73,7 +77,8 @@ class CheckinSerializer(I18nAwareModelSerializer):
class OrderDownloadsField(serializers.Field):
def to_representation(self, instance: Order):
if instance.status != Order.STATUS_PAID:
return []
if instance.status != Order.STATUS_PENDING or instance.require_approval or not instance.event.settings.ticket_download_pending:
return []
request = self.context['request']
res = []
@@ -96,7 +101,8 @@ class OrderDownloadsField(serializers.Field):
class PositionDownloadsField(serializers.Field):
def to_representation(self, instance: OrderPosition):
if instance.order.status != Order.STATUS_PAID:
return []
if instance.order.status != Order.STATUS_PENDING or instance.order.require_approval or not instance.order.event.settings.ticket_download_pending:
return []
if instance.addon_to_id and not instance.order.event.settings.ticket_download_addons:
return []
if not instance.item.admission and not instance.order.event.settings.ticket_download_nonadm:
@@ -125,12 +131,19 @@ class PdfDataSerializer(serializers.Field):
res = {}
ev = instance.subevent or instance.order.event
# This needs to have some extra performance improvements to avoid creating hundreds of queries when
# we serialize a list.
pdfvars = get_variables(instance.order.event)
for k, f in pdfvars.items():
if 'vars' not in self.context:
self.context['vars'] = get_variables(self.context['request'].event)
for k, f in self.context['vars'].items():
res[k] = f['evaluate'](instance, instance.order, ev)
for k, v in ev.meta_data.items():
if not hasattr(ev, '_cached_meta_data'):
ev._cached_meta_data = ev.meta_data
for k, v in ev._cached_meta_data.items():
res['meta:' + k] = v
return res
@@ -155,23 +168,61 @@ class OrderPositionSerializer(I18nAwareModelSerializer):
self.fields.pop('pdf_data')
class OrderPaymentTypeField(serializers.Field):
# TODO: Remove after pretix 2.2
def to_representation(self, instance: Order):
t = None
for p in instance.payments.all():
t = p.provider
return t
class OrderPaymentDateField(serializers.DateField):
# TODO: Remove after pretix 2.2
def to_representation(self, instance: Order):
t = None
for p in instance.payments.all():
t = p.payment_date or t
if t:
return super().to_representation(t.date())
class OrderFeeSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderFee
fields = ('fee_type', 'value', 'description', 'internal_type', 'tax_rate', 'tax_value', 'tax_rule')
class OrderPaymentSerializer(I18nAwareModelSerializer):
class Meta:
model = OrderPayment
fields = ('local_id', 'state', 'amount', 'created', 'payment_date', 'provider')
class OrderRefundSerializer(I18nAwareModelSerializer):
payment = SlugRelatedField(slug_field='local_id', read_only=True)
class Meta:
model = OrderRefund
fields = ('local_id', 'state', 'source', 'amount', 'payment', 'created', 'execution_date', 'provider')
class OrderSerializer(I18nAwareModelSerializer):
invoice_address = InvoiceAddressSerializer()
positions = OrderPositionSerializer(many=True)
fees = OrderFeeSerializer(many=True)
downloads = OrderDownloadsField(source='*')
payments = OrderPaymentSerializer(many=True)
refunds = OrderRefundSerializer(many=True)
payment_date = OrderPaymentDateField(source='*')
payment_provider = OrderPaymentTypeField(source='*')
class Meta:
model = Order
fields = ('code', 'status', 'secret', 'email', 'locale', 'datetime', 'expires', 'payment_date',
'payment_provider', 'fees', 'total', 'comment', 'invoice_address', 'positions', 'downloads',
'checkin_attention', 'last_modified')
'checkin_attention', 'last_modified', 'payments', 'refunds', 'require_approval')
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
@@ -298,15 +349,15 @@ class OrderPositionCreateSerializer(I18nAwareModelSerializer):
if data.get('item'):
if data.get('item').has_variations:
if not data.get('variation'):
raise ValidationError('You should specify a variation for this item.')
raise ValidationError({'variation': ['You should specify a variation for this item.']})
else:
if data.get('variation').item != data.get('item'):
raise ValidationError(
'The specified variation does not belong to the specified item.'
{'variation': ['The specified variation does not belong to the specified item.']}
)
elif data.get('variation'):
raise ValidationError(
'You cannot specify a variation for this item.'
{'variation': ['You cannot specify a variation for this item.']}
)
return data
@@ -340,11 +391,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
comment = serializers.CharField(required=False, allow_blank=True)
payment_provider = serializers.CharField(required=True)
payment_info = CompatibleJSONField(required=False)
consume_carts = serializers.ListField(child=serializers.CharField(), required=False)
class Meta:
model = Order
fields = ('code', 'status', 'email', 'locale', 'payment_provider', 'fees', 'comment',
'invoice_address', 'positions', 'checkin_attention', 'payment_info')
'invoice_address', 'positions', 'checkin_attention', 'payment_info', 'consume_carts')
def validate_payment_provider(self, pp):
if pp not in self.context['event'].get_payment_providers():
@@ -367,73 +419,142 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
raise ValidationError(
'An order cannot be empty.'
)
errs = [{} for p in data]
if any([p.get('positionid') for p in data]):
if not all([p.get('positionid') for p in data]):
raise ValidationError(
'If you set position IDs manually, you need to do so for all positions.'
)
for i, p in enumerate(data):
if not p.get('positionid'):
errs[i]['positionid'] = [
'If you set position IDs manually, you need to do so for all positions.'
]
raise ValidationError(errs)
last_non_add_on = None
last_posid = 0
for p in data:
for i, p in enumerate(data):
if p['positionid'] != last_posid + 1:
raise ValidationError("Position IDs need to be consecutive.")
errs[i]['positionid'] = [
'Position IDs need to be consecutive.'
]
if p.get('addon_to') and p['addon_to'] != last_non_add_on:
raise ValidationError("If you set addon_to, you need to make sure that the referenced "
"position ID exists and is transmitted directly before its add-ons.")
errs[i]['addon_to'] = [
"If you set addon_to, you need to make sure that the referenced "
"position ID exists and is transmitted directly before its add-ons."
]
if not p.get('addon_to'):
last_non_add_on = p['positionid']
last_posid = p['positionid']
elif any([p.get('addon_to') for p in data]):
raise ValidationError("If you set addon_to, you need to specify position IDs manually.")
errs = [
{'positionid': ["If you set addon_to on any position, you need to specify position IDs manually."]}
for p in data
]
if any(errs):
raise ValidationError(errs)
return data
def create(self, validated_data):
fees_data = validated_data.pop('fees') if 'fees' in validated_data else []
positions_data = validated_data.pop('positions') if 'positions' in validated_data else []
payment_provider = validated_data.pop('payment_provider')
payment_info = validated_data.pop('payment_info', '{}')
if 'invoice_address' in validated_data:
ia = InvoiceAddress(**validated_data.pop('invoice_address'))
else:
ia = None
with self.context['event'].lock():
with self.context['event'].lock() as now_dt:
quotadiff = Counter()
for pos_data in positions_data:
consume_carts = validated_data.pop('consume_carts', [])
delete_cps = []
quota_avail_cache = {}
if consume_carts:
for cp in CartPosition.objects.filter(event=self.context['event'], cart_id__in=consume_carts):
quotas = (cp.variation.quotas.filter(subevent=cp.subevent)
if cp.variation else cp.item.quotas.filter(subevent=cp.subevent))
for quota in quotas:
if quota not in quota_avail_cache:
quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] += 1
if cp.expires > now_dt:
quotadiff.subtract(quotas)
delete_cps.append(cp)
errs = [{} for p in positions_data]
for i, pos_data in enumerate(positions_data):
new_quotas = (pos_data.get('variation').quotas.filter(subevent=pos_data.get('subevent'))
if pos_data.get('variation')
else pos_data.get('item').quotas.filter(subevent=pos_data.get('subevent')))
if len(new_quotas) == 0:
errs[i]['item'] = [ugettext_lazy('The product "{}" is not assigned to a quota.').format(
str(pos_data.get('item'))
)]
else:
for quota in new_quotas:
if quota not in quota_avail_cache:
quota_avail_cache[quota] = list(quota.availability())
if quota_avail_cache[quota][1] is not None:
quota_avail_cache[quota][1] -= 1
if quota_avail_cache[quota][1] < 0:
errs[i]['item'] = [
ugettext_lazy('There is not enough quota available on quota "{}" to perform the operation.').format(
quota.name
)
]
quotadiff.update(new_quotas)
for quota, diff in quotadiff.items():
avail = quota.availability()
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise ValidationError(
'There is not enough quota available on quota "{}" to perform the operation.'.format(
quota.name
)
)
if any(errs):
raise ValidationError({'positions': errs})
if validated_data.get('locale', None) is None:
validated_data['locale'] = self.context['event'].settings.locale
order = Order(event=self.context['event'], **validated_data)
order.set_expires(subevents=[p['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'))
if order.total == Decimal('0.00') and validated_data.get('status') != Order.STATUS_PAID:
order.payment_provider = 'free'
order.status = Order.STATUS_PAID
elif order.payment_provider == "free" and order.total != Decimal('0.00'):
raise ValidationError('You cannot use the "free" payment provider for non-free orders.')
if validated_data.get('status') == Order.STATUS_PAID:
order.payment_date = now()
order.meta_info = "{}"
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
)
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=now(),
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:
ia.order = order
ia.save()
pos_map = {}
for pos_data in positions_data:
answers_data = pos_data.pop('answers')
addon_to = pos_data.pop('addon_to')
answers_data = pos_data.pop('answers', [])
addon_to = pos_data.pop('addon_to', None)
pos = OrderPosition(**pos_data)
pos.order = order
pos._calculate_tax()
@@ -442,9 +563,12 @@ class OrderCreateSerializer(I18nAwareModelSerializer):
pos.save()
pos_map[pos.positionid] = pos
for answ_data in answers_data:
options = answ_data.pop('options')
options = answ_data.pop('options', [])
answ = pos.answers.create(**answ_data)
answ.options.add(*options)
for cp in delete_cps:
cp.delete()
for fee_data in fees_data:
f = OrderFee(**fee_data)
f.order = order
@@ -471,3 +595,27 @@ class InvoiceSerializer(I18nAwareModelSerializer):
'introductory_text', 'additional_text', 'payment_provider_text', 'footer_text', 'lines',
'foreign_currency_display', 'foreign_currency_rate', 'foreign_currency_rate_date',
'internal_reference')
class OrderRefundCreateSerializer(I18nAwareModelSerializer):
payment = serializers.IntegerField(required=False, allow_null=True)
provider = serializers.CharField(required=True, allow_null=False, allow_blank=False)
info = CompatibleJSONField(required=False)
class Meta:
model = OrderRefund
fields = ('state', 'source', 'amount', 'payment', 'execution_date', 'provider', 'info')
def create(self, validated_data):
pid = validated_data.pop('payment', None)
if pid:
try:
p = self.context['order'].payments.get(local_id=pid)
except OrderPayment.DoesNotExist:
raise ValidationError('Unknown payment ID.')
else:
p = None
order = OrderRefund(order=self.context['order'], payment=p, **validated_data)
order.save()
return order

View File

@@ -8,7 +8,7 @@ class WaitingListSerializer(I18nAwareModelSerializer):
class Meta:
model = WaitingListEntry
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent')
fields = ('id', 'created', 'email', 'voucher', 'item', 'variation', 'locale', 'subevent', 'priority')
read_only_fields = ('id', 'created', 'voucher')
def validate(self, data):

View File

@@ -4,8 +4,11 @@ from django.apps import apps
from django.conf.urls import include, url
from rest_framework import routers
from pretix.api.views import cart
from .views import (
checkin, event, item, oauth, order, organizer, voucher, waitinglist,
checkin, device, event, item, oauth, order, organizer, voucher,
waitinglist,
)
router = routers.DefaultRouter()
@@ -13,6 +16,7 @@ router.register(r'organizers', organizer.OrganizerViewSet)
orga_router = routers.DefaultRouter()
orga_router.register(r'events', event.EventViewSet)
orga_router.register(r'subevents', event.SubEventViewSet)
event_router = routers.DefaultRouter()
event_router.register(r'subevents', event.SubEventViewSet)
@@ -28,6 +32,7 @@ event_router.register(r'invoices', order.InvoiceViewSet)
event_router.register(r'taxrules', event.TaxRuleViewSet)
event_router.register(r'waitinglistentries', waitinglist.WaitingListViewSet)
event_router.register(r'checkinlists', checkin.CheckinListViewSet)
event_router.register(r'cartpositions', cart.CartPositionViewSet)
checkinlist_router = routers.DefaultRouter()
checkinlist_router.register(r'positions', checkin.CheckinListPositionViewSet)
@@ -39,6 +44,10 @@ item_router = routers.DefaultRouter()
item_router.register(r'variations', item.ItemVariationViewSet)
item_router.register(r'addons', item.ItemAddOnViewSet)
order_router = routers.DefaultRouter()
order_router.register(r'payments', order.PaymentViewSet)
order_router.register(r'refunds', order.RefundViewSet)
# Force import of all plugins to give them a chance to register URLs with the router
for app in apps.get_app_configs():
if hasattr(app, 'PretixPluginMeta'):
@@ -54,7 +63,12 @@ urlpatterns = [
include(question_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/checkinlists/(?P<list>[^/]+)/',
include(checkinlist_router.urls)),
url(r'^organizers/(?P<organizer>[^/]+)/events/(?P<event>[^/]+)/orders/(?P<order>[^/]+)/', include(order_router.urls)),
url(r"^oauth/authorize$", oauth.AuthorizationView.as_view(), name="authorize"),
url(r"^oauth/token$", oauth.TokenView.as_view(), name="token"),
url(r"^oauth/revoke_token$", oauth.RevokeTokenView.as_view(), name="revoke-token"),
url(r"^device/initialize$", device.InitializeView.as_view(), name="device.initialize"),
url(r"^device/update$", device.UpdateView.as_view(), name="device.update"),
url(r"^device/roll$", device.RollKeyView.as_view(), name="device.roll"),
url(r"^device/revoke$", device.RevokeKeyView.as_view(), name="device.revoke"),
]

View File

@@ -37,6 +37,9 @@ class ConditionalListView:
if_unmodified_since = request.META.get('HTTP_IF_UNMODIFIED_SINCE')
if if_unmodified_since:
if_unmodified_since = parse_http_date_safe(if_unmodified_since)
if not hasattr(request, 'event'):
return super().list(request, **kwargs)
lmd = request.event.logentry_set.filter(
content_type__model=self.queryset.model._meta.model_name,
content_type__app_label=self.queryset.model._meta.app_label,

View File

@@ -0,0 +1,46 @@
from django.db import transaction
from rest_framework import status, viewsets
from rest_framework.filters import OrderingFilter
from rest_framework.mixins import CreateModelMixin, DestroyModelMixin
from rest_framework.response import Response
from pretix.api.serializers.cart import (
CartPositionCreateSerializer, CartPositionSerializer,
)
from pretix.base.models import CartPosition
class CartPositionViewSet(CreateModelMixin, DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = CartPositionSerializer
queryset = CartPosition.objects.none()
filter_backends = (OrderingFilter,)
ordering = ('datetime',)
ordering_fields = ('datetime', 'cart_id')
lookup_field = 'id'
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_queryset(self):
return CartPosition.objects.filter(
event=self.request.event,
cart_id__endswith="@api"
)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['event'] = self.request.event
return ctx
def create(self, request, *args, **kwargs):
serializer = CartPositionCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
self.perform_create(serializer)
cp = serializer.instance
serializer = CartPositionSerializer(cp, context=serializer.context)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
serializer.save()

View File

@@ -1,6 +1,7 @@
from django.core.exceptions import ValidationError
from django.db.models import Count, F, Max, OuterRef, Prefetch, Subquery
from django.db.models.functions import Coalesce
from django.http import Http404
from django.shortcuts import get_object_or_404
from django.utils.functional import cached_property
from django.utils.timezone import now
@@ -32,7 +33,7 @@ class CheckinListViewSet(viewsets.ModelViewSet):
serializer_class = CheckinListSerializer
queryset = CheckinList.objects.none()
filter_backends = (DjangoFilterBackend,)
filter_class = CheckinListFilter
filterset_class = CheckinListFilter
permission = 'can_view_orders'
write_permission = 'can_change_event_settings'
@@ -175,13 +176,16 @@ class CheckinListPositionViewSet(viewsets.ReadOnlyModelViewSet):
},
}
filter_class = CheckinOrderPositionFilter
filterset_class = CheckinOrderPositionFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
@cached_property
def checkinlist(self):
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
try:
return get_object_or_404(CheckinList, event=self.request.event, pk=self.kwargs.get("list"))
except ValueError:
raise Http404()
def get_queryset(self):
cqs = Checkin.objects.filter(

View File

@@ -0,0 +1,113 @@
import logging
from django.utils.timezone import now
from rest_framework import serializers
from rest_framework.exceptions import ValidationError
from rest_framework.response import Response
from rest_framework.views import APIView
from pretix.api.auth.device import DeviceTokenAuthentication
from pretix.base.models import Device
from pretix.base.models.devices import generate_api_token
logger = logging.getLogger(__name__)
class InitializationRequestSerializer(serializers.Serializer):
token = serializers.CharField(max_length=190)
hardware_brand = serializers.CharField(max_length=190)
hardware_model = serializers.CharField(max_length=190)
software_brand = serializers.CharField(max_length=190)
software_version = serializers.CharField(max_length=190)
class UpdateRequestSerializer(serializers.Serializer):
hardware_brand = serializers.CharField(max_length=190)
hardware_model = serializers.CharField(max_length=190)
software_brand = serializers.CharField(max_length=190)
software_version = serializers.CharField(max_length=190)
class DeviceSerializer(serializers.ModelSerializer):
organizer = serializers.SlugRelatedField(slug_field='slug', read_only=True)
class Meta:
model = Device
fields = [
'organizer', 'device_id', 'unique_serial', 'api_token',
'name'
]
class InitializeView(APIView):
authentication_classes = tuple()
permission_classes = tuple()
def post(self, request, format=None):
serializer = InitializationRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
try:
device = Device.objects.get(initialization_token=serializer.validated_data.get('token'))
except Device.DoesNotExist:
raise ValidationError({'token': ['Unknown initialization token.']})
if device.initialized:
raise ValidationError({'token': ['This initialization token has already been used.']})
device.initialized = now()
device.hardware_brand = serializer.validated_data.get('hardware_brand')
device.hardware_model = serializer.validated_data.get('hardware_model')
device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version')
device.api_token = generate_api_token()
device.save()
device.log_action('pretix.device.initialized', data=serializer.validated_data, auth=device)
serializer = DeviceSerializer(device)
return Response(serializer.data)
class UpdateView(APIView):
authentication_classes = (DeviceTokenAuthentication,)
def post(self, request, format=None):
serializer = UpdateRequestSerializer(data=request.data)
serializer.is_valid(raise_exception=True)
device = request.auth
device.hardware_brand = serializer.validated_data.get('hardware_brand')
device.hardware_model = serializer.validated_data.get('hardware_model')
device.software_brand = serializer.validated_data.get('software_brand')
device.software_version = serializer.validated_data.get('software_version')
device.save()
device.log_action('pretix.device.updated', data=serializer.validated_data, auth=device)
serializer = DeviceSerializer(device)
return Response(serializer.data)
class RollKeyView(APIView):
authentication_classes = (DeviceTokenAuthentication,)
def post(self, request, format=None):
device = request.auth
device.api_token = generate_api_token()
device.save()
device.log_action('pretix.device.keyroll', auth=device)
serializer = DeviceSerializer(device)
return Response(serializer.data)
class RevokeKeyView(APIView):
authentication_classes = (DeviceTokenAuthentication,)
def post(self, request, format=None):
device = request.auth
device.api_token = None
device.save()
device.log_action('pretix.device.revoked', auth=device)
serializer = DeviceSerializer(device)
return Response(serializer.data)

View File

@@ -1,5 +1,7 @@
import django_filters
from django.db import transaction
from django.db.models import ProtectedError
from django.db.models import ProtectedError, Q
from django.utils.timezone import now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import filters, viewsets
from rest_framework.exceptions import PermissionDenied
@@ -10,20 +12,79 @@ from pretix.api.serializers.event import (
TaxRuleSerializer,
)
from pretix.api.views import ConditionalListView
from pretix.base.models import Event, ItemCategory, TaxRule
from pretix.base.models import (
Device, Event, ItemCategory, TaxRule, TeamAPIToken,
)
from pretix.base.models.event import SubEvent
from pretix.helpers.dicts import merge_dicts
class EventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta:
model = Event
fields = ['is_public', 'live', 'has_subevents']
def ends_after_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
)
)
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = (
Q(has_subevents=False) &
Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class EventViewSet(viewsets.ModelViewSet):
serializer_class = EventSerializer
queryset = Event.objects.none()
lookup_field = 'slug'
lookup_url_kwarg = 'event'
permission_classes = (EventCRUDPermission,)
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filterset_class = EventFilter
def get_queryset(self):
return self.request.organizer.events.prefetch_related('meta_values', 'meta_values__property')
if isinstance(self.request.auth, (TeamAPIToken, Device)):
qs = self.request.auth.get_events_with_any_permission()
elif self.request.user.is_authenticated:
qs = self.request.user.get_events_with_any_permission(self.request).filter(
organizer=self.request.organizer
)
return qs.prefetch_related(
'meta_values', 'meta_values__property'
)
def perform_update(self, serializer):
current_live_value = serializer.instance.live
@@ -120,19 +181,62 @@ class CloneEventViewSet(viewsets.ModelViewSet):
class SubEventFilter(FilterSet):
is_past = django_filters.rest_framework.BooleanFilter(method='is_past_qs')
is_future = django_filters.rest_framework.BooleanFilter(method='is_future_qs')
ends_after = django_filters.rest_framework.IsoDateTimeFilter(method='ends_after_qs')
class Meta:
model = SubEvent
fields = ['active']
fields = ['active', 'event__live']
def ends_after_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=value))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=value))
)
return queryset.filter(expr)
def is_past_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__lt=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__lt=now()))
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
def is_future_qs(self, queryset, name, value):
expr = Q(
Q(Q(date_to__isnull=True) & Q(date_from__gte=now()))
| Q(Q(date_to__isnull=False) & Q(date_to__gte=now()))
)
if value:
return queryset.filter(expr)
else:
return queryset.exclude(expr)
class SubEventViewSet(ConditionalListView, viewsets.ReadOnlyModelViewSet):
serializer_class = SubEventSerializer
queryset = ItemCategory.objects.none()
filter_backends = (DjangoFilterBackend, filters.OrderingFilter)
filter_class = SubEventFilter
filterset_class = SubEventFilter
def get_queryset(self):
return self.request.event.subevents.prefetch_related(
if getattr(self.request, 'event', None):
qs = self.request.event.subevents
elif isinstance(self.request.auth, (TeamAPIToken, Device)):
qs = SubEvent.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.auth.get_events_with_any_permission()
)
elif self.request.user.is_authenticated:
qs = SubEvent.objects.filter(
event__organizer=self.request.organizer,
event__in=self.request.user.get_events_with_any_permission()
)
return qs.prefetch_related(
'subeventitem_set', 'subeventitemvariation_set'
)

View File

@@ -41,8 +41,8 @@ class ItemViewSet(ConditionalListView, viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
filter_class = ItemFilter
permission = 'can_change_items'
filterset_class = ItemFilter
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -92,7 +92,7 @@ class ItemVariationViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('id',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -154,7 +154,7 @@ class ItemAddOnViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('id',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -207,10 +207,10 @@ class ItemCategoryViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = ItemCategorySerializer
queryset = ItemCategory.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
filter_class = ItemCategoryFilter
filterset_class = ItemCategoryFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -261,10 +261,11 @@ class QuestionViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuestionSerializer
queryset = Question.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
filter_class = QuestionFilter
filterset_class = QuestionFilter
ordering_fields = ('id', 'position')
ordering = ('position', 'id')
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
return self.request.event.questions.prefetch_related('options').all()
@@ -307,7 +308,7 @@ class QuestionOptionViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter,)
ordering_fields = ('id', 'position')
ordering = ('position',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):
@@ -359,10 +360,10 @@ class QuotaViewSet(ConditionalListView, viewsets.ModelViewSet):
serializer_class = QuotaSerializer
queryset = Quota.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter,)
filter_class = QuotaFilter
filterset_class = QuotaFilter
ordering_fields = ('id', 'size')
ordering = ('id',)
permission = 'can_change_items'
permission = None
write_permission = 'can_change_items'
def get_queryset(self):

View File

@@ -3,12 +3,13 @@ import datetime
import django_filters
import pytz
from django.db import transaction
from django.db.models import Q
from django.db.models import Prefetch, Q
from django.db.models.functions import Concat
from django.http import FileResponse
from django.shortcuts import get_object_or_404
from django.utils.timezone import make_aware, now
from django_filters.rest_framework import DjangoFilterBackend, FilterSet
from rest_framework import serializers, status, viewsets
from rest_framework import mixins, serializers, status, viewsets
from rest_framework.decorators import detail_route
from rest_framework.exceptions import (
APIException, NotFound, PermissionDenied, ValidationError,
@@ -19,20 +20,23 @@ from rest_framework.response import Response
from pretix.api.models import OAuthAccessToken
from pretix.api.serializers.order import (
InvoiceSerializer, OrderCreateSerializer, OrderPositionSerializer,
OrderSerializer,
InvoiceSerializer, OrderCreateSerializer, OrderPaymentSerializer,
OrderPositionSerializer, OrderRefundCreateSerializer,
OrderRefundSerializer, OrderSerializer,
)
from pretix.base.models import (
Invoice, Order, OrderPosition, Quota, TeamAPIToken,
Device, Invoice, Order, OrderPayment, OrderPosition, OrderRefund, Quota,
TeamAPIToken,
)
from pretix.base.payment import PaymentException
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_pdf, invoice_qualified,
regenerate_invoice,
)
from pretix.base.services.mail import SendMailException
from pretix.base.services.orders import (
OrderError, cancel_order, extend_order, mark_order_expired,
mark_order_paid, mark_order_refunded,
OrderChangeManager, OrderError, approve_order, cancel_order, deny_order,
extend_order, mark_order_expired, mark_order_refunded,
)
from pretix.base.services.tickets import (
get_cachedticket_for_order, get_cachedticket_for_position,
@@ -41,14 +45,14 @@ from pretix.base.signals import order_placed, register_ticket_outputs
class OrderFilter(FilterSet):
email = django_filters.CharFilter(name='email', lookup_expr='iexact')
code = django_filters.CharFilter(name='code', lookup_expr='iexact')
status = django_filters.CharFilter(name='status', lookup_expr='iexact')
modified_since = django_filters.IsoDateTimeFilter(name='last_modified', lookup_expr='gte')
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')
class Meta:
model = Order
fields = ['code', 'status', 'email', 'locale']
fields = ['code', 'status', 'email', 'locale', 'require_approval']
class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
@@ -57,7 +61,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('datetime',)
ordering_fields = ('datetime', 'code', 'status')
filter_class = OrderFilter
filterset_class = OrderFilter
lookup_field = 'code'
permission = 'can_view_orders'
write_permission = 'can_change_orders'
@@ -68,13 +72,34 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
return ctx
def get_queryset(self):
return self.request.event.orders.prefetch_related(
'positions', 'positions__checkins', 'positions__item', 'positions__answers', 'positions__answers__options',
'positions__answers__question', 'fees'
qs = self.request.event.orders.prefetch_related(
'fees', 'payments', 'refunds', 'refunds__payment'
).select_related(
'invoice_address'
)
if self.request.query_params.get('pdf_data', 'false') == 'true':
qs = qs.prefetch_related(
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
Prefetch('addons', OrderPosition.objects.select_related('item', 'variation'))
)
)
)
else:
qs = qs.prefetch_related(
Prefetch(
'positions',
OrderPosition.objects.all().prefetch_related(
'checkins', 'item', 'variation', 'answers', 'answers__options', 'answers__question',
)
)
)
return qs
def _get_output_provider(self, identifier):
responses = register_ticket_outputs.send(self.request.event)
for receiver, response in responses:
@@ -122,14 +147,33 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
order = self.get_object()
if order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED):
ps = order.pending_sum
try:
mark_order_paid(
order, manual=True,
user=request.user if request.user.is_authenticated else None,
auth=request.auth,
p = order.payments.get(
state__in=(OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED),
provider='manual',
amount=ps
)
except OrderPayment.DoesNotExist:
order.payments.filter(state__in=(OrderPayment.PAYMENT_STATE_PENDING,
OrderPayment.PAYMENT_STATE_CREATED)) \
.update(state=OrderPayment.PAYMENT_STATE_CANCELED)
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='manual',
amount=ps,
fee=None
)
try:
p.confirm(auth=self.request.auth,
user=self.request.user if request.user.is_authenticated else None,
count_waitinglist=False)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
@@ -154,11 +198,48 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
order,
user=request.user if request.user.is_authenticated else None,
api_token=request.auth if isinstance(request.auth, TeamAPIToken) else None,
device=request.auth if isinstance(request.auth, Device) else None,
oauth_application=request.auth.application if isinstance(request.auth, OAuthAccessToken) else None,
send_mail=send_mail
)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def approve(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
order = self.get_object()
try:
approve_order(
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
send_mail=send_mail,
)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except OrderError as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def deny(self, request, **kwargs):
send_mail = request.data.get('send_email', True)
comment = request.data.get('comment', '')
order = self.get_object()
try:
deny_order(
order,
user=request.user if request.user.is_authenticated else None,
auth=request.auth if isinstance(request.auth, (Device, TeamAPIToken, OAuthAccessToken)) else None,
send_mail=send_mail,
comment=comment,
)
except OrderError as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def mark_pending(self, request, **kwargs):
order = self.get_object()
@@ -170,7 +251,6 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
)
order.status = Order.STATUS_PENDING
order.payment_manual = True
order.save()
order.log_action(
'pretix.event.order.unpaid',
@@ -209,7 +289,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
mark_order_refunded(
order,
user=request.user if request.user.is_authenticated else None,
api_token=(request.auth if isinstance(request.auth, TeamAPIToken) else None),
auth=(request.auth if isinstance(request.auth, (TeamAPIToken, OAuthAccessToken, Device)) else None),
)
return self.retrieve(request, [], **kwargs)
@@ -285,7 +365,7 @@ class OrderViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
class OrderPositionFilter(FilterSet):
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
has_checkin = django_filters.rest_framework.BooleanFilter(method='has_checkin_qs')
attendee_name = django_filters.CharFilter(method='attendee_name_qs')
search = django_filters.CharFilter(method='search_qs')
@@ -313,18 +393,22 @@ class OrderPositionFilter(FilterSet):
'secret': ['exact'],
'order__status': ['exact', 'in'],
'addon_to': ['exact', 'in'],
'subevent': ['exact', 'in']
'subevent': ['exact', 'in'],
'pseudonymization_id': ['exact'],
'voucher__code': ['exact'],
'voucher': ['exact'],
}
class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
class OrderPositionViewSet(mixins.DestroyModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPositionSerializer
queryset = OrderPosition.objects.none()
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('order__datetime', 'positionid')
ordering_fields = ('order__code', 'order__datetime', 'positionid', 'attendee_name', 'order__status',)
filter_class = OrderPositionFilter
filterset_class = OrderPositionFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'
def get_queryset(self):
return OrderPosition.objects.filter(order__event=self.request.event).prefetch_related(
@@ -365,11 +449,232 @@ class OrderPositionViewSet(viewsets.ReadOnlyModelViewSet):
)
return resp
def perform_destroy(self, instance):
try:
ocm = OrderChangeManager(
instance.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
notify=False
)
ocm.cancel(instance)
ocm.commit()
except OrderError as e:
raise ValidationError(str(e))
except Quota.QuotaExceededException as e:
raise ValidationError(str(e))
class PaymentViewSet(viewsets.ReadOnlyModelViewSet):
serializer_class = OrderPaymentSerializer
queryset = OrderPayment.objects.none()
permission = 'can_view_orders'
write_permission = 'can_change_orders'
lookup_field = 'local_id'
def get_queryset(self):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.payments.all()
@detail_route(methods=['POST'])
def confirm(self, request, **kwargs):
payment = self.get_object()
force = request.data.get('force', False)
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
try:
payment.confirm(user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth,
count_waitinglist=False,
force=force)
except Quota.QuotaExceededException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except PaymentException as e:
return Response({'detail': str(e)}, status=status.HTTP_400_BAD_REQUEST)
except SendMailException:
pass
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def refund(self, request, **kwargs):
payment = self.get_object()
amount = serializers.DecimalField(max_digits=10, decimal_places=2).to_internal_value(
request.data.get('amount', str(payment.amount))
)
mark_refunded = request.data.get('mark_refunded', False)
if payment.state != OrderPayment.PAYMENT_STATE_CONFIRMED:
return Response({'detail': 'Invalid state of payment.'}, status=status.HTTP_400_BAD_REQUEST)
full_refund_possible = payment.payment_provider.payment_refund_supported(payment)
partial_refund_possible = payment.payment_provider.payment_partial_refund_supported(payment)
available_amount = payment.amount - payment.refunded_amount
if amount <= 0:
return Response({'amount': ['Invalid refund amount.']}, status=status.HTTP_400_BAD_REQUEST)
if amount > available_amount:
return Response(
{'amount': ['Invalid refund amount, only {} are available to refund.'.format(available_amount)]},
status=status.HTTP_400_BAD_REQUEST)
if amount != payment.amount and not partial_refund_possible:
return Response({'amount': ['Partial refund not available for this payment method.']},
status=status.HTTP_400_BAD_REQUEST)
if amount == payment.amount and not full_refund_possible:
return Response({'amount': ['Full refund not available for this payment method.']},
status=status.HTTP_400_BAD_REQUEST)
r = payment.order.refunds.create(
payment=payment,
source=OrderRefund.REFUND_SOURCE_ADMIN,
state=OrderRefund.REFUND_STATE_CREATED,
amount=amount,
provider=payment.provider
)
try:
r.payment_provider.execute_refund(r)
except PaymentException as e:
r.state = OrderRefund.REFUND_STATE_FAILED
r.save()
return Response({'detail': 'External error: {}'.format(str(e))},
status=status.HTTP_400_BAD_REQUEST)
else:
payment.order.log_action('pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
if payment.order.pending_sum > 0:
if mark_refunded:
mark_order_refunded(payment.order,
user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth)
else:
payment.order.status = Order.STATUS_PENDING
payment.order.set_expires(
now(),
payment.order.event.subevents.filter(
id__in=payment.order.positions.values_list('subevent_id', flat=True))
)
payment.order.save()
return Response(OrderRefundSerializer(r).data, status=status.HTTP_200_OK)
@detail_route(methods=['POST'])
def cancel(self, request, **kwargs):
payment = self.get_object()
if payment.state not in (OrderPayment.PAYMENT_STATE_PENDING, OrderPayment.PAYMENT_STATE_CREATED):
return Response({'detail': 'Invalid state of payment'}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
payment.state = OrderPayment.PAYMENT_STATE_CANCELED
payment.save()
payment.order.log_action('pretix.event.order.payment.canceled', {
'local_id': payment.local_id,
'provider': payment.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
class RefundViewSet(CreateModelMixin, viewsets.ReadOnlyModelViewSet):
serializer_class = OrderRefundSerializer
queryset = OrderRefund.objects.none()
permission = 'can_view_orders'
write_permission = 'can_change_orders'
lookup_field = 'local_id'
def get_queryset(self):
order = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return order.refunds.all()
@detail_route(methods=['POST'])
def cancel(self, request, **kwargs):
refund = self.get_object()
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_EXTERNAL):
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
with transaction.atomic():
refund.state = OrderRefund.REFUND_STATE_CANCELED
refund.save()
refund.order.log_action('pretix.event.order.refund.canceled', {
'local_id': refund.local_id,
'provider': refund.provider,
}, user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def process(self, request, **kwargs):
refund = self.get_object()
if refund.state != OrderRefund.REFUND_STATE_EXTERNAL:
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
if request.data.get('mark_refunded', False):
mark_order_refunded(refund.order, user=self.request.user if self.request.user.is_authenticated else None,
auth=self.request.auth)
else:
refund.order.status = Order.STATUS_PENDING
refund.order.set_expires(
now(),
refund.order.event.subevents.filter(
id__in=refund.order.positions.values_list('subevent_id', flat=True))
)
refund.order.save()
return self.retrieve(request, [], **kwargs)
@detail_route(methods=['POST'])
def done(self, request, **kwargs):
refund = self.get_object()
if refund.state not in (OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT):
return Response({'detail': 'Invalid state of refund'}, status=status.HTTP_400_BAD_REQUEST)
refund.done(user=self.request.user if self.request.user.is_authenticated else None, auth=self.request.auth)
return self.retrieve(request, [], **kwargs)
def get_serializer_context(self):
ctx = super().get_serializer_context()
ctx['order'] = get_object_or_404(Order, code=self.kwargs['order'], event=self.request.event)
return ctx
def create(self, request, *args, **kwargs):
mark_refunded = request.data.pop('mark_refunded', False)
serializer = OrderRefundCreateSerializer(data=request.data, context=self.get_serializer_context())
serializer.is_valid(raise_exception=True)
with transaction.atomic():
self.perform_create(serializer)
r = serializer.instance
serializer = OrderRefundSerializer(r, context=serializer.context)
r.order.log_action(
'pretix.event.order.refund.created', {
'local_id': r.local_id,
'provider': r.provider,
},
user=request.user if request.user.is_authenticated else None,
auth=request.auth
)
if mark_refunded:
mark_order_refunded(
r.order,
user=request.user if request.user.is_authenticated else None,
auth=(request.auth if request.auth else None),
)
headers = self.get_success_headers(serializer.data)
return Response(serializer.data, status=status.HTTP_201_CREATED, headers=headers)
def perform_create(self, serializer):
serializer.save()
class InvoiceFilter(FilterSet):
refers = django_filters.CharFilter(method='refers_qs')
number = django_filters.CharFilter(method='nr_qs')
order = django_filters.CharFilter(name='order', lookup_expr='code__iexact')
order = django_filters.CharFilter(field_name='order', lookup_expr='code__iexact')
def refers_qs(self, queryset, name, value):
return queryset.annotate(
@@ -396,7 +701,7 @@ class InvoiceViewSet(viewsets.ReadOnlyModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('nr',)
ordering_fields = ('nr', 'date')
filter_class = InvoiceFilter
filterset_class = InvoiceFilter
permission = 'can_view_orders'
lookup_url_kwarg = 'number'
lookup_field = 'nr'

View File

@@ -12,7 +12,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
lookup_url_kwarg = 'organizer'
def get_queryset(self):
if self.request.user.is_authenticated():
if self.request.user.is_authenticated:
if self.request.user.has_active_staff_session(self.request.session.session_key):
return Organizer.objects.all()
elif isinstance(self.request.auth, OAuthAccessToken):
@@ -23,5 +23,7 @@ class OrganizerViewSet(viewsets.ReadOnlyModelViewSet):
)
else:
return Organizer.objects.filter(pk__in=self.request.user.teams.values_list('organizer', flat=True))
elif hasattr(self.request.auth, 'organizer_id'):
return Organizer.objects.filter(pk=self.request.auth.organizer_id)
else:
return Organizer.objects.filter(pk=self.request.auth.team.organizer_id)

View File

@@ -34,7 +34,7 @@ class VoucherViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('id',)
ordering_fields = ('id', 'code', 'max_usages', 'valid_until', 'value')
filter_class = VoucherFilter
filterset_class = VoucherFilter
permission = 'can_view_vouchers'
write_permission = 'can_change_vouchers'

View File

@@ -28,7 +28,7 @@ class WaitingListViewSet(viewsets.ModelViewSet):
filter_backends = (DjangoFilterBackend, OrderingFilter)
ordering = ('created',)
ordering_fields = ('id', 'created', 'email', 'item')
filter_class = WaitingListFilter
filterset_class = WaitingListFilter
permission = 'can_view_orders'
write_permission = 'can_change_orders'

View File

@@ -12,6 +12,7 @@ class PretixBaseConfig(AppConfig):
from . import exporters # NOQA
from . import invoice # NOQA
from . import notifications # NOQA
from . import email # NOQA
from .services import auth, export, mail, tickets, cart, orders, invoices, cleanup, update_check, quotas, notifications # NOQA
try:

View File

@@ -1,7 +1,18 @@
import logging
from smtplib import SMTPRecipientsRefused, SMTPSenderRefused
import bleach
import markdown
from django.conf import settings
from django.core.mail.backends.smtp import EmailBackend
from django.dispatch import receiver
from django.template.loader import get_template
from django.utils.translation import ugettext_lazy as _
from inlinestyler.utils import inline_css
from pretix.base.models import Event, Order
from pretix.base.signals import register_html_mail_renderers
from pretix.base.templatetags.rich_text import markdown_compile
logger = logging.getLogger('pretix.base.email')
@@ -24,3 +35,103 @@ class CustomSMTPBackend(EmailBackend):
raise SMTPRecipientsRefused(senderrs)
finally:
self.close()
class BaseHTMLMailRenderer:
"""
This is the base class for all HTML e-mail renderers.
"""
def __init__(self, event: Event):
self.event = event
def __str__(self):
return self.identifier
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order=None) -> str:
"""
This method should generate the HTML part of the email.
:param plain_body: The body of the email in plain text.
:param plain_signature: The signature with event organizer contact details in plain text.
:param subject: The email subject.
:param order: The order if this email is connected to one, otherwise ``None``.
:return: An HTML string
"""
raise NotImplementedError()
@property
def verbose_name(self) -> str:
"""
A human-readable name for this renderer. This should be short but self-explanatory.
"""
raise NotImplementedError() # NOQA
@property
def identifier(self) -> str:
"""
A short and unique identifier for this renderer.
This should only contain lowercase letters and in most cases will be the same as your package name or prefixed
with your package name.
"""
raise NotImplementedError() # NOQA
@property
def thumbnail_filename(self) -> str:
"""
A file name discoverable in the static file storage that contains a preview of your renderer. This should
be with aspect resolution 4:3.
"""
raise NotImplementedError() # NOQA
@property
def is_available(self) -> bool:
"""
This renderer will only be available if this returns ``True``. You can use this to limit this renderer
to certain events. Defaults to ``True``.
"""
return True
class TemplateBasedMailRenderer(BaseHTMLMailRenderer):
@property
def template_name(self):
raise NotImplemented
def render(self, plain_body: str, plain_signature: str, subject: str, order: Order) -> str:
body_md = bleach.linkify(markdown_compile(plain_body))
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
'body': body_md,
'subject': str(subject),
'color': '#8E44B3'
}
if self.event:
htmlctx['event'] = self.event
htmlctx['color'] = self.event.settings.primary_color
if plain_signature:
signature_md = plain_signature.replace('\n', '<br>\n')
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
htmlctx['signature'] = signature_md
if order:
htmlctx['order'] = order
tpl = get_template(self.template_name)
body_html = inline_css(tpl.render(htmlctx))
return body_html
class ClassicMailRenderer(TemplateBasedMailRenderer):
verbose_name = _('pretix default')
identifier = 'classic'
thumbnail_filename = 'pretixbase/email/thumb.png'
template_name = 'pretixbase/email/plainwrapper.html'
@receiver(register_html_mail_renderers, dispatch_uid="pretixbase_email_renderers")
def base_renderers(sender, **kwargs):
return [ClassicMailRenderer]

View File

@@ -5,9 +5,12 @@ from zipfile import ZipFile
import dateutil.parser
from django import forms
from django.db.models import Exists, OuterRef, Q
from django.dispatch import receiver
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import OrderPayment
from ..exporter import BaseExporter
from ..services.invoices import invoice_pdf_task
from ..signals import register_data_exporters
@@ -21,7 +24,14 @@ class InvoiceExporter(BaseExporter):
qs = self.event.invoices.filter(shredded=False)
if form_data.get('payment_provider'):
qs = qs.filter(order__payment_provider=form_data.get('payment_provider'))
qs = qs.annotate(
has_payment_with_provider=Exists(
OrderPayment.objects.filter(
Q(order=OuterRef('pk')) & Q(provider=form_data.get('payment_provider'))
)
)
)
qs = qs.filter(has_payment_with_provider=1)
if form_data.get('date_from'):
date_value = form_data.get('date_from')
@@ -84,10 +94,10 @@ class InvoiceExporter(BaseExporter):
(k, v.verbose_name) for k, v in self.event.get_payment_providers().items()
],
required=False,
help_text=_('Only include invoices for orders that are currently set to this payment provider. '
'Note that this might include some invoices of other payment providers or misses '
'some invoices if the payment provider of an order has been changed and a new invoice '
'has been generated.')
help_text=_('Only include invoices for orders that have at least one payment attempt '
'with this payment provider. '
'Note that this might include some invoices of orders which in the end have been '
'fully or partially paid with a different provider.')
)),
]
)

View File

@@ -5,13 +5,13 @@ from decimal import Decimal
import pytz
from defusedcsv import csv
from django import forms
from django.db.models import Sum
from django.db.models import DateTimeField, Max, OuterRef, Subquery, Sum
from django.dispatch import receiver
from django.utils.formats import localize
from django.utils.translation import ugettext as _, ugettext_lazy
from pretix.base.models import InvoiceAddress, Order, OrderPosition
from pretix.base.models.orders import OrderFee
from pretix.base.models.orders import OrderFee, OrderPayment, OrderRefund
from ..exporter import BaseExporter
from ..signals import register_data_exporters
@@ -55,7 +55,19 @@ class OrderListExporter(BaseExporter):
tz = pytz.timezone(self.event.settings.timezone)
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
qs = self.event.orders.all().select_related('invoice_address').prefetch_related('invoices')
p_date = OrderPayment.objects.filter(
order=OuterRef('pk'),
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
payment_date__isnull=False
).values('order').annotate(
m=Max('payment_date')
).values(
'm'
).order_by()
qs = self.event.orders.annotate(
payment_date=Subquery(p_date, output_field=DateTimeField())
).select_related('invoice_address').prefetch_related('invoices')
if form_data['paid_only']:
qs = qs.filter(status=Order.STATUS_PAID)
tax_rates = self._get_all_tax_rates(qs)
@@ -63,7 +75,7 @@ class OrderListExporter(BaseExporter):
headers = [
_('Order code'), _('Order total'), _('Status'), _('Email'), _('Order date'),
_('Company'), _('Name'), _('Address'), _('ZIP code'), _('City'), _('Country'), _('VAT ID'),
_('Payment date'), _('Payment type'), _('Fees'), _('Order locale')
_('Date of last payment'), _('Fees'), _('Order locale')
]
for tr in tax_rates:
@@ -77,11 +89,6 @@ class OrderListExporter(BaseExporter):
writer.writerow(headers)
provider_names = {
k: v.verbose_name
for k, v in self.event.get_payment_providers().items()
}
full_fee_sum_cache = {
o['order__id']: o['grosssum'] for o in
OrderFee.objects.values('tax_rate', 'order__id').order_by().annotate(grosssum=Sum('value'))
@@ -114,7 +121,8 @@ class OrderListExporter(BaseExporter):
order.invoice_address.street,
order.invoice_address.zipcode,
order.invoice_address.city,
order.invoice_address.country if order.invoice_address.country else order.invoice_address.country_old,
order.invoice_address.country if order.invoice_address.country else
order.invoice_address.country_old,
order.invoice_address.vat_id,
]
except InvoiceAddress.DoesNotExist:
@@ -122,14 +130,14 @@ class OrderListExporter(BaseExporter):
row += [
order.payment_date.astimezone(tz).strftime('%Y-%m-%d') if order.payment_date else '',
provider_names.get(order.payment_provider, order.payment_provider),
localize(full_fee_sum_cache.get(order.id) or Decimal('0.00')),
order.locale,
]
for tr in tax_rates:
taxrate_values = sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
fee_taxrate_values = fee_sum_cache.get((order.id, tr), {'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
fee_taxrate_values = fee_sum_cache.get((order.id, tr),
{'grosssum': Decimal('0.00'), 'taxsum': Decimal('0.00')})
row += [
localize(taxrate_values['grosssum'] + fee_taxrate_values['grosssum']),
@@ -144,6 +152,77 @@ class OrderListExporter(BaseExporter):
return '{}_orders.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
class PaymentListExporter(BaseExporter):
identifier = 'paymentlistcsv'
verbose_name = ugettext_lazy('List of payments and refunds (CSV)')
@property
def export_form_fields(self):
return OrderedDict(
[
('successful_only',
forms.BooleanField(
label=_('Only successful payments'),
initial=True,
required=False
)),
]
)
def render(self, form_data: dict):
output = io.StringIO()
tz = pytz.timezone(self.event.settings.timezone)
writer = csv.writer(output, quoting=csv.QUOTE_NONNUMERIC, delimiter=",")
provider_names = {
k: v.verbose_name
for k, v in self.event.get_payment_providers().items()
}
payments = OrderPayment.objects.filter(
order__event=self.event,
).order_by('created')
refunds = OrderRefund.objects.filter(
order__event=self.event
).order_by('created')
if form_data['successful_only']:
payments = payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
)
refunds = refunds.filter(
state=OrderRefund.REFUND_STATE_DONE,
)
objs = sorted(list(payments) + list(refunds), key=lambda o: o.created)
headers = [
_('Order'), _('Payment ID'), _('Creation date'), _('Completion date'), _('Status'),
_('Amount'), _('Payment method')
]
writer.writerow(headers)
for obj in objs:
if isinstance(obj, OrderPayment) and obj.payment_date:
d2 = obj.payment_date.astimezone(tz).date().strftime('%Y-%m-%d')
elif isinstance(obj, OrderRefund) and obj.execution_date:
d2 = obj.execution_date.astimezone(tz).date().strftime('%Y-%m-%d')
else:
d2 = ''
row = [
obj.order.code,
obj.full_id,
obj.created.astimezone(tz).date().strftime('%Y-%m-%d'),
d2,
obj.get_state_display(),
localize(obj.amount * (-1 if isinstance(obj, OrderRefund) else 1)),
provider_names.get(obj.provider, obj.provider)
]
writer.writerow(row)
return '{}_payments.csv'.format(self.event.slug), 'text/csv', output.getvalue().encode("utf-8")
class QuotaListExporter(BaseExporter):
identifier = 'quotalistcsv'
verbose_name = ugettext_lazy('Quota availabilities (CSV)')
@@ -180,6 +259,11 @@ def register_orderlist_exporter(sender, **kwargs):
return OrderListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_paymentlist")
def register_paymentlist_exporter(sender, **kwargs):
return PaymentListExporter
@receiver(register_data_exporters, dispatch_uid="exporter_quotalist")
def register_quotalist_exporter(sender, **kwargs):
return QuotaListExporter

View File

@@ -57,7 +57,7 @@ class SettingsForm(i18nfield.forms.I18nFormMixin, HierarkeyForm):
kwargs['locales'] = self.locales
kwargs['initial'] = self.obj.settings.freeze()
super().__init__(*args, **kwargs)
for f in self.fields.values():
for k, f in self.fields.items():
if isinstance(f, (RelativeDateTimeField, RelativeDateField)):
f.set_event(self.obj)

View File

@@ -39,7 +39,7 @@ class LoginForm(forms.Form):
password = self.cleaned_data.get('password')
if email and password:
self.user_cache = authenticate(email=email.lower(), password=password)
self.user_cache = authenticate(request=self.request, email=email.lower(), password=password)
if self.user_cache is None:
raise forms.ValidationError(
self.error_messages['invalid_login'],
@@ -180,12 +180,4 @@ class PasswordForgotForm(forms.Form):
super().__init__(*args, **kwargs)
def clean_email(self):
email = self.cleaned_data['email']
try:
self.cleaned_data['user'] = User.objects.get(email=email)
return email
except User.DoesNotExist:
raise forms.ValidationError(
_("We are unable to find a user matching the data you provided."),
code='unknown_user'
)
return self.cleaned_data['email']

View File

@@ -16,6 +16,7 @@ from pretix.base.forms.widgets import (
)
from pretix.base.models import InvoiceAddress, Question
from pretix.base.models.tax import EU_COUNTRIES
from pretix.control.forms import SplitDateTimeField
from pretix.helpers.i18n import get_format_without_seconds
from pretix.presale.signals import question_form_fields
@@ -143,7 +144,7 @@ class BaseQuestionsForm(forms.Form):
widget=TimePickerWidget(time_format=get_format_without_seconds('TIME_INPUT_FORMATS')),
)
elif q.type == Question.TYPE_DATETIME:
field = forms.SplitDateTimeField(
field = SplitDateTimeField(
label=q.question, required=q.required,
help_text=q.help_text,
initial=dateutil.parser.parse(initial.answer).astimezone(tz) if initial and initial.answer else None,
@@ -199,6 +200,16 @@ class BaseInvoiceAddressForm(forms.ModelForm):
if event.settings.invoice_name_required:
self.fields['name'].required = True
elif event.settings.invoice_address_company_required:
self.initial['is_business'] = True
self.fields['is_business'].widget = BusinessBooleanRadio(require_business=True)
self.fields['company'].required = True
self.fields['company'].widget.is_required = True
self.fields['company'].widget.attrs['required'] = 'required'
del self.fields['company'].widget.attrs['data-display-dependency']
if 'vat_id' in self.fields:
del self.fields['vat_id'].widget.attrs['data-display-dependency']
else:
self.fields['company'].widget.attrs['data-required-if'] = '#id_is_business_1'
self.fields['name'].widget.attrs['data-required-if'] = '#id_is_business_0'

View File

@@ -2,6 +2,7 @@ import os
from django import forms
from django.utils.formats import get_format
from django.utils.functional import lazy
from django.utils.timezone import now
from django.utils.translation import ugettext_lazy as _
@@ -92,14 +93,20 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
date_attrs['class'] += ' datepickerfield'
time_attrs['class'] += ' timepickerfield'
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
date_attrs['placeholder'] = now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df)
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
time_attrs['placeholder'] = now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
def date_placeholder():
df = date_format or get_format('DATE_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=12, day=31, hour=18, minute=0, second=0, microsecond=0
).strftime(df)
def time_placeholder():
tf = time_format or get_format('TIME_INPUT_FORMATS')[0]
return now().replace(
year=2000, month=1, day=1, hour=0, minute=0, second=0, microsecond=0
).strftime(tf)
date_attrs['placeholder'] = lazy(date_placeholder, str)
time_attrs['placeholder'] = lazy(time_placeholder, str)
widgets = (
forms.DateInput(attrs=date_attrs, format=date_format),
@@ -110,14 +117,22 @@ class SplitDateTimePickerWidget(forms.SplitDateTimeWidget):
class BusinessBooleanRadio(forms.RadioSelect):
def __init__(self, attrs=None):
choices = (
('individual', _('Individual customer')),
('business', _('Business customer')),
)
def __init__(self, require_business=False, attrs=None):
self.require_business = require_business
if self.require_business:
choices = (
('business', _('Business customer')),
)
else:
choices = (
('individual', _('Individual customer')),
('business', _('Business customer')),
)
super().__init__(attrs, choices)
def format_value(self, value):
if self.require_business:
return 'business'
try:
return {True: 'business', False: 'individual'}[value]
except KeyError:
@@ -125,6 +140,8 @@ class BusinessBooleanRadio(forms.RadioSelect):
def value_from_datadict(self, data, files, name):
value = data.get(name)
if self.require_business:
return True
return {
'business': True,
True: True,

View File

@@ -184,10 +184,15 @@ class BaseReportlabInvoiceRenderer(BaseInvoiceRenderer):
class ThumbnailingImageReader(ImageReader):
def resize(self, width, height, dpi):
if width is None:
width = height * self._image.size[0] / self._image.size[1]
if height is None:
height = width * self._image.size[1] / self._image.size[0]
self._image.thumbnail(
size=(int(width * dpi / 72), int(height * dpi / 72)),
resample=BICUBIC
)
return width, height
class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
@@ -204,6 +209,18 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
canvas.restoreState()
def _draw_invoice_to(self, canvas):
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 85 * mm, 50 * mm)
p_size = p.wrap(85 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
def _draw_invoice_from(self, canvas):
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 70 * mm, 50 * mm)
p_size = p.wrap(70 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
def _on_first_page(self, canvas: Canvas, doc):
canvas.setCreator('pretix.eu')
canvas.setTitle(pgettext('invoice', 'Invoice {num}').format(num=self.invoice.number))
@@ -220,20 +237,14 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
textobject.textLine(pgettext('invoice', 'Invoice from').upper())
canvas.drawText(textobject)
p = Paragraph(self.invoice.invoice_from.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 70 * mm, 50 * mm)
p_size = p.wrap(70 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 17) * mm - p_size[1])
self._draw_invoice_from(canvas)
textobject = canvas.beginText(25 * mm, (297 - 50) * mm)
textobject.setFont('OpenSansBd', 8)
textobject.textLine(pgettext('invoice', 'Invoice to').upper())
canvas.drawText(textobject)
p = Paragraph(self.invoice.invoice_to.strip().replace('\n', '<br />\n'), style=self.stylesheet['Normal'])
p.wrapOn(canvas, 85 * mm, 50 * mm)
p_size = p.wrap(85 * mm, 50 * mm)
p.drawOn(canvas, 25 * mm, (297 - 52) * mm - p_size[1])
self._draw_invoice_to(canvas)
textobject = canvas.beginText(125 * mm, (297 - 38) * mm)
textobject.setFont('OpenSansBd', 8)
@@ -368,7 +379,7 @@ class ClassicInvoiceRenderer(BaseReportlabInvoiceRenderer):
if self.invoice.internal_reference:
story.append(Paragraph(
pgettext('invoice', 'Your reference: {reference}').format(reference=self.invoice.internal_reference),
pgettext('invoice', 'Customer reference: {reference}').format(reference=self.invoice.internal_reference),
self.stylesheet['Normal']
))

View File

@@ -8,10 +8,10 @@ class Command(BaseCommand):
help = "Rebuild static files and language files"
def handle(self, *args, **options):
call_command('compilemessages', verbosity=1, interactive=False)
call_command('compilejsi18n', verbosity=1, interactive=False)
call_command('compilemessages', verbosity=1)
call_command('compilejsi18n', verbosity=1)
call_command('collectstatic', verbosity=1, interactive=False)
call_command('compress', verbosity=1, interactive=False)
call_command('compress', verbosity=1)
try:
gs = GlobalSettingsObject()
del gs.settings.update_check_last

View File

@@ -3,8 +3,8 @@ from urllib.parse import urlsplit
import pytz
from django.conf import settings
from django.core.urlresolvers import get_script_prefix
from django.http import HttpRequest, HttpResponse
from django.urls import get_script_prefix
from django.utils import timezone, translation
from django.utils.cache import patch_vary_headers
from django.utils.deprecation import MiddlewareMixin
@@ -172,6 +172,12 @@ class SecurityMiddleware(MiddlewareMixin):
return resp
resp['X-XSS-Protection'] = '1'
# We just need to have a P3P, not matter whats in there
# https://blogs.msdn.microsoft.com/ieinternals/2013/09/17/a-quick-look-at-p3p/
# https://github.com/pretix/pretix/issues/765
resp['P3P'] = 'CP=\"ALL DSP COR CUR ADM TAI OUR IND COM NAV INT\"'
h = {
'default-src': ["{static}"],
'script-src': ['{static}', 'https://checkout.stripe.com', 'https://js.stripe.com'],

View File

@@ -0,0 +1,424 @@
# Generated by Django 2.0.8 on 2018-09-11 14:50
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.db.models import F
from django.db.models.functions import Concat
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext as _
import pretix.base.models.auth
import pretix.base.validators
from pretix.base.i18n import language
def create_checkin_lists(apps, schema_editor):
Event = apps.get_model('pretixbase', 'Event')
Checkin = apps.get_model('pretixbase', 'Checkin')
EventSettingsStore = apps.get_model('pretixbase', 'Event_SettingsStore')
for e in Event.objects.all():
locale = EventSettingsStore.objects.filter(object=e, key='locale').first()
if locale:
locale = locale.value
else:
locale = settings.LANGUAGE_CODE
if e.has_subevents:
for se in e.subevents.all():
with language(locale):
cl = e.checkin_lists.create(name=se.name, subevent=se, all_products=True)
Checkin.objects.filter(position__subevent=se, position__order__event=e).update(list=cl)
else:
with language(locale):
cl = e.checkin_lists.create(name=_('Default list'), all_products=True)
Checkin.objects.filter(position__order__event=e).update(list=cl)
def set_full_invoice_no(app, schema_editor):
Invoice = app.get_model('pretixbase', 'Invoice')
Invoice.objects.all().update(
full_invoice_no=Concat(F('prefix'), F('invoice_no'))
)
def set_position(apps, schema_editor):
Question = apps.get_model('pretixbase', 'Question')
for q in Question.objects.all():
for i, option in enumerate(q.options.all()):
option.position = i
option.save()
def set_is_staff(apps, schema_editor):
User = apps.get_model('pretixbase', 'User')
User.objects.filter(is_superuser=True).update(is_staff=True)
def set_identifiers(apps, schema_editor):
Question = apps.get_model('pretixbase', 'Question')
QuestionOption = apps.get_model('pretixbase', 'QuestionOption')
for q in Question.objects.select_related('event'):
if not q.identifier:
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True:
code = get_random_string(length=8, allowed_chars=charset)
if not Question.objects.filter(event=q.event, identifier=code).exists():
q.identifier = code
q.save()
break
for q in QuestionOption.objects.select_related('question', 'question__event'):
if not q.identifier:
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
while True:
code = get_random_string(length=8, allowed_chars=charset)
if not QuestionOption.objects.filter(question__event=q.question.event, identifier=code).exists():
q.identifier = code
q.save()
break
class Migration(migrations.Migration):
replaces = [('pretixbase', '0077_auto_20171124_1629'), ('pretixbase', '0078_auto_20171206_1603'),
('pretixbase', '0079_auto_20180115_0855'), ('pretixbase', '0080_question_ask_during_checkin'),
('pretixbase', '0081_auto_20180220_1031'), ('pretixbase', '0082_auto_20180222_0938'),
('pretixbase', '0083_auto_20180228_2102'), ('pretixbase', '0084_questionoption_position'),
('pretixbase', '0085_auto_20180312_1119'), ('pretixbase', '0086_auto_20180320_1219'),
('pretixbase', '0087_auto_20180317_1952'), ('pretixbase', '0088_auto_20180328_1217')]
dependencies = [
('pretixbase', '0076_orderfee_squashed_0082_invoiceaddress_internal_reference'),
]
operations = [
migrations.AlterField(
model_name='event',
name='slug',
field=models.SlugField(
help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes, and must be '
'unique among your events. We recommend some kind of abbreviation or a date with less than '
'10 characters that can be easily remembered, but you can also choose to use a random '
'value. This will be used in URLs, order codes, invoice numbers, and bank transfer '
'references.',
validators=[django.core.validators.RegexValidator(
message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'),
pretix.base.validators.EventSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.AlterField(
model_name='eventmetaproperty',
name='name',
field=models.CharField(db_index=True,
help_text='Can not contain spaces or special characters except underscores',
max_length=50, validators=[django.core.validators.RegexValidator(
message='The property name may only contain letters, numbers and underscores.',
regex='^[a-zA-Z0-9_]+$')], verbose_name='Name'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.SlugField(
help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can '
'only be used once. This is being used in URLs to refer to your organizer accounts and your'
' events.',
validators=[django.core.validators.RegexValidator(
message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'),
pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.CreateModel(
name='CheckinList',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('name', models.CharField(max_length=190)),
('all_products',
models.BooleanField(default=True, verbose_name='All products (including newly created ones)')),
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='checkin_lists',
to='pretixbase.Event')),
('subevent', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to='pretixbase.SubEvent', verbose_name='Date')),
('limit_products',
models.ManyToManyField(blank=True, to='pretixbase.Item', verbose_name='Limit to products')),
],
),
migrations.AddField(
model_name='checkin',
name='list',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
related_name='checkins', to='pretixbase.CheckinList'),
),
migrations.RunPython(
code=create_checkin_lists,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AlterField(
model_name='checkin',
name='list',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='checkins',
to='pretixbase.CheckinList'),
),
migrations.CreateModel(
name='NotificationSetting',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('action_type', models.CharField(max_length=255)),
('method', models.CharField(choices=[('mail', 'E-mail')], max_length=255)),
('event', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to='pretixbase.Event')),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to=settings.AUTH_USER_MODEL)),
('enabled', models.BooleanField(default=True)),
],
),
migrations.AlterUniqueTogether(
name='notificationsetting',
unique_together={('user', 'action_type', 'event', 'method')},
),
migrations.AddField(
model_name='logentry',
name='visible',
field=models.BooleanField(default=True),
),
migrations.AlterField(
model_name='notificationsetting',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
related_name='notification_settings', to='pretixbase.Event'),
),
migrations.AlterField(
model_name='notificationsetting',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='notification_settings',
to=settings.AUTH_USER_MODEL),
),
migrations.AddField(
model_name='user',
name='notifications_send',
field=models.BooleanField(default=True, help_text='If turned off, you will not get any notifications.',
verbose_name='Receive notifications according to my settings below'),
),
migrations.AddField(
model_name='user',
name='notifications_token',
field=models.CharField(default=pretix.base.models.auth.generate_notifications_token, max_length=255),
),
migrations.AddField(
model_name='invoice',
name='full_invoice_no',
field=models.CharField(db_index=True, default='', max_length=190),
preserve_default=False,
),
migrations.AlterField(
model_name='question',
name='type',
field=models.CharField(
choices=[('N', 'Number'), ('S', 'Text (one line)'), ('T', 'Multiline text'), ('B', 'Yes/No'),
('C', 'Choose one from a list'), ('M', 'Choose multiple from a list'), ('F', 'File upload'),
('D', 'Date'), ('H', 'Time'), ('W', 'Date and time')], max_length=5,
verbose_name='Question type'),
),
migrations.RunPython(
code=set_full_invoice_no,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AddField(
model_name='question',
name='ask_during_checkin',
field=models.BooleanField(default=False,
help_text='This will only work if you handle your check-in with pretixdroid 1.8 '
'or '
'newer or pretixdesk 0.2 or newer.',
verbose_name='Ask during check-in instead of in the ticket buying process'),
),
migrations.AddField(
model_name='checkinlist',
name='include_pending',
field=models.BooleanField(default=False,
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 0.3.0 or newer or '
'pretixdroid 1.9 or newer.',
verbose_name='Include pending orders'),
),
migrations.AlterField(
model_name='event',
name='presale_end',
field=models.DateTimeField(blank=True,
help_text='Optional. No products will be sold after this date. If you do not '
'set '
'this value, the presale will end after the end date of your event.',
null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='logentry',
name='event',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL,
to='pretixbase.Event'),
),
migrations.AlterField(
model_name='subevent',
name='presale_end',
field=models.DateTimeField(blank=True,
help_text='Optional. No products will be sold after this date. If you do not '
'set '
'this value, the presale will end after the end date of your event.',
null=True, verbose_name='End of presale'),
),
migrations.AlterField(
model_name='user',
name='require_2fa',
field=models.BooleanField(default=False, verbose_name='Two-factor authentification is required to log in'),
),
migrations.AddField(
model_name='order',
name='checkin_attention',
field=models.BooleanField(default=False,
help_text='If you set this, the check-in app will show a visible warning that '
'tickets of this order require special attention. This will not show '
'any '
'details or custom message, so you need to brief your check-in staff '
'how '
'to handle these cases.',
verbose_name='Requires special attention'),
),
migrations.AddField(
model_name='taxrule',
name='custom_rules',
field=models.TextField(blank=True, null=True),
),
migrations.AlterField(
model_name='orderfee',
name='fee_type',
field=models.CharField(
choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'),
('other', 'Other fees')], max_length=100),
),
migrations.AlterModelOptions(
name='questionoption',
options={'ordering': ('position', 'id'), 'verbose_name': 'Question option',
'verbose_name_plural': 'Question options'},
),
migrations.AddField(
model_name='questionoption',
name='position',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='question',
name='position',
field=models.PositiveIntegerField(default=0, verbose_name='Position'),
),
migrations.RunPython(
code=set_position,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AddField(
model_name='question',
name='identifier',
field=models.CharField(default='', max_length=190),
preserve_default=False,
),
migrations.AddField(
model_name='questionoption',
name='identifier',
field=models.CharField(default='', max_length=190),
preserve_default=False,
),
migrations.AlterField(
model_name='user',
name='locale',
field=models.CharField(
choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)'), ('nl', 'Dutch'),
('da', 'Danish'), ('pt-br', 'Portuguese (Brazil)')], default='en', max_length=50,
verbose_name='Language'),
),
migrations.RunPython(
code=set_identifiers,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AlterField(
model_name='cachedcombinedticket',
name='file',
field=models.FileField(blank=True, max_length=255, null=True,
upload_to=pretix.base.models.orders.cachedcombinedticket_name),
),
migrations.AlterField(
model_name='cachedticket',
name='file',
field=models.FileField(blank=True, max_length=255, null=True,
upload_to=pretix.base.models.orders.cachedticket_name),
),
migrations.AlterField(
model_name='invoice',
name='file',
field=models.FileField(blank=True, max_length=255, null=True,
upload_to=pretix.base.models.invoices.invoice_filename),
),
migrations.AlterField(
model_name='question',
name='identifier',
field=models.CharField(
help_text='You can enter any value here to make it easier to match the data with other sources. If '
'you do '
'not input one, we will generate one automatically.',
max_length=190, verbose_name='Internal identifier'),
),
migrations.AlterField(
model_name='questionanswer',
name='file',
field=models.FileField(blank=True, max_length=255, null=True,
upload_to=pretix.base.models.orders.answerfile_name),
),
migrations.RunPython(
code=set_is_staff,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.RemoveField(
model_name='user',
name='is_superuser',
),
migrations.CreateModel(
name='StaffSession',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('date_start', models.DateTimeField(auto_now_add=True)),
('date_end', models.DateTimeField(blank=True, null=True)),
('session_key', models.CharField(max_length=255)),
('comment', models.TextField()),
],
),
migrations.CreateModel(
name='StaffSessionAuditLog',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('datetime', models.DateTimeField(auto_now_add=True)),
('url', models.CharField(max_length=255)),
('session', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='logs',
to='pretixbase.StaffSession')),
('impersonating', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL)),
('method', models.CharField(default='GET', max_length=255)),
],
options={
'ordering': ('datetime',),
},
),
migrations.AddField(
model_name='staffsession',
name='user',
field=models.ForeignKey(default=None, on_delete=django.db.models.deletion.CASCADE,
to=settings.AUTH_USER_MODEL),
preserve_default=False,
),
migrations.AlterModelOptions(
name='staffsession',
options={'ordering': ('date_start',)},
),
migrations.AlterField(
model_name='item',
name='picture',
field=models.ImageField(blank=True, max_length=255, null=True,
upload_to=pretix.base.models.items.itempicture_upload_to,
verbose_name='Product picture'),
),
]

View File

@@ -9,6 +9,7 @@ class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0088_auto_20180328_1217'),
('pretixapi', '0001_initial')
]
operations = [

View File

@@ -0,0 +1,85 @@
# Generated by Django 2.0.8 on 2018-09-11 14:54
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
from django.utils.crypto import get_random_string
def set_pids(apps, schema_editor):
OrderPosition = apps.get_model('pretixbase', 'OrderPosition') # noqa
taken = set()
charset = list('ABCDEFGHJKLMNPQRSTUVWXYZ3789')
for op in OrderPosition.objects.iterator():
while True:
code = get_random_string(length=10, allowed_chars=charset)
if code not in taken:
op.pseudonymization_id = code
taken.add(code)
break
op.save(update_fields=['pseudonymization_id'])
class Migration(migrations.Migration):
replaces = [('pretixbase', '0090_auto_20180509_0917'), ('pretixbase', '0091_auto_20180513_1641'),
('pretixbase', '0092_auto_20180511_1224'), ('pretixbase', '0093_auto_20180528_1432'),
('pretixbase', '0094_auto_20180604_1119'), ('pretixbase', '0095_auto_20180604_1129')]
dependencies = [
('pretixbase', '0089_auto_20180315_1322'),
('pretixapi', '0001_initial'),
]
operations = [
migrations.AddField(
model_name='item',
name='internal_name',
field=models.CharField(blank=True,
help_text='If you set this, this will be used instead of the public name in the '
'backend.',
max_length=255, null=True, verbose_name='Internal name'),
),
migrations.AddField(
model_name='itemcategory',
name='internal_name',
field=models.CharField(blank=True,
help_text='If you set this, this will be used instead of the public name in the '
'backend.',
max_length=255, null=True, verbose_name='Internal name'),
),
migrations.AddField(
model_name='order',
name='last_modified',
field=models.DateTimeField(auto_now=True, db_index=True),
),
migrations.AddField(
model_name='item',
name='original_price',
field=models.DecimalField(blank=True, decimal_places=2,
help_text='If set, this will be displayed next to the current price to show '
'that the current price is a discounted one. This is just a cosmetic '
'setting and will not actually impact pricing.',
max_digits=7, null=True, verbose_name='Original price'),
),
migrations.AddField(
model_name='orderposition',
name='pseudonymization_id',
field=models.CharField(db_index=True, max_length=16, null=True, unique=True),
),
migrations.RunPython(
code=set_pids,
reverse_code=django.db.migrations.operations.special.RunPython.noop,
),
migrations.AlterField(
model_name='orderposition',
name='pseudonymization_id',
field=models.CharField(db_index=True, default='', max_length=16, unique=True),
preserve_default=False,
),
migrations.AddField(
model_name='logentry',
name='oauth_application',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT,
to=settings.OAUTH2_PROVIDER_APPLICATION_MODEL),
),
]

View File

@@ -0,0 +1,81 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-07-22 08:01
from __future__ import unicode_literals
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0095_auto_20180604_1129'),
]
operations = [
migrations.CreateModel(
name='OrderPayment',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('local_id', models.PositiveIntegerField()),
('state', models.CharField(choices=[('created', 'created'), ('pending', 'pending'), ('confirmed', 'confirmed'), ('canceled', 'canceled'), ('failed', 'failed'), ('refunded', 'refunded')], max_length=190)),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')),
('created', models.DateTimeField(auto_now_add=True)),
('payment_date', models.DateTimeField(blank=True, null=True)),
('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
('info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
('migrated', models.BooleanField(default=False)),
],
options={
'ordering': ('local_id',),
},
),
migrations.CreateModel(
name='OrderRefund',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('local_id', models.PositiveIntegerField()),
('state', models.CharField(choices=[('external', 'started externally'), ('created', 'created'), ('transit', 'in transit'), ('done', 'done'), ('failed', 'failed'), ('canceled', 'canceled')], max_length=190)),
('source', models.CharField(choices=[('admin', 'Organizer'), ('buyer', 'Customer'), ('external', 'External')], max_length=190)),
('amount', models.DecimalField(decimal_places=2, max_digits=10, verbose_name='Amount')),
('created', models.DateTimeField(auto_now_add=True)),
('execution_date', models.DateTimeField(blank=True, null=True)),
('provider', models.CharField(blank=True, max_length=255, null=True, verbose_name='Payment provider')),
('info', models.TextField(blank=True, null=True, verbose_name='Payment information')),
('order', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.Order', verbose_name='Order')),
('payment', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='refunds', to='pretixbase.OrderPayment')),
],
options={
'ordering': ('local_id',),
},
),
migrations.AlterModelOptions(
name='quota',
options={'ordering': ('name',), 'verbose_name': 'Quota', 'verbose_name_plural': 'Quotas'},
),
migrations.AlterField(
model_name='orderfee',
name='fee_type',
field=models.CharField(choices=[('payment', 'Payment fee'), ('shipping', 'Shipping fee'), ('service', 'Service fee'), ('other', 'Other fees'), ('giftcard', 'Gift card')], max_length=100),
),
migrations.AlterField(
model_name='team',
name='can_change_organizer_settings',
field=models.BooleanField(default=False, help_text='Someone with this setting can get access to most data of all of your events, i.e. via privacy reports, so be careful who you add to this team!', verbose_name='Can change organizer settings'),
),
migrations.AlterField(
model_name='user',
name='require_2fa',
field=models.BooleanField(default=False, verbose_name='Two-factor authentication is required to log in'),
),
migrations.AddField(
model_name='orderpayment',
name='fee',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='payments', to='pretixbase.OrderFee'),
),
migrations.AddField(
model_name='orderpayment',
name='order',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='payments', to='pretixbase.Order', verbose_name='Order'),
),
]

View File

@@ -0,0 +1,118 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.13 on 2018-07-22 08:04
from __future__ import unicode_literals
from django.db import migrations
def create_payments(apps, schema_editor):
Order = apps.get_model('pretixbase', 'Order') # noqa
OrderPayment = apps.get_model('pretixbase', 'OrderPayment') # noqa
OrderRefund = apps.get_model('pretixbase', 'OrderRefund') # noqa
payments = []
refunds = []
for o in Order.objects.filter(payments__isnull=True).iterator():
if o.status == 'n' or o.status == 'e':
payments.append(OrderPayment(
local_id=1,
state='created',
amount=o.total,
order=o,
provider=o.payment_provider,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
))
pass
elif o.status == 'p':
payments.append(OrderPayment(
local_id=1,
state='confirmed',
amount=o.total,
order=o,
provider=o.payment_provider,
payment_date=o.payment_date,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
))
elif o.status == 'r':
p = OrderPayment.objects.create(
local_id=1,
state='refunded',
amount=o.total,
order=o,
provider=o.payment_provider,
payment_date=o.payment_date,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
)
refunds.append(OrderRefund(
local_id=1,
state='done',
amount=o.total,
order=o,
provider=o.payment_provider,
info=o.payment_info,
source='admin',
payment=p
))
elif o.status == 'c':
payments.append(OrderPayment(
local_id=1,
state='canceled',
amount=o.total,
order=o,
provider=o.payment_provider,
payment_date=o.payment_date,
info=o.payment_info,
migrated=True,
fee=o.fees.filter(fee_type="payment", internal_type=o.payment_provider).first(),
))
if len(payments) > 500:
OrderPayment.objects.bulk_create(payments)
payments.clear()
if len(refunds) > 500:
OrderRefund.objects.bulk_create(refunds)
refunds.clear()
if len(payments) > 0:
OrderPayment.objects.bulk_create(payments)
if len(refunds) > 0:
OrderRefund.objects.bulk_create(refunds)
def notifications(apps, schema_editor):
NotificationSetting = apps.get_model('pretixbase', 'NotificationSetting')
for n in NotificationSetting.objects.filter(action_type='pretix.event.action_required'):
n.pk = None
n.action_type = 'pretix.event.order.refund.created.externally'
n.save()
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0096_auto_20180722_0801'),
]
operations = [
migrations.RunPython(create_payments, migrations.RunPython.noop),
migrations.RunPython(notifications, migrations.RunPython.noop),
migrations.RemoveField(
model_name='order',
name='payment_date',
),
migrations.RemoveField(
model_name='order',
name='payment_info',
),
migrations.RemoveField(
model_name='order',
name='payment_manual',
),
migrations.RemoveField(
model_name='order',
name='payment_provider',
),
]

View File

@@ -0,0 +1,56 @@
# Generated by Django 2.0.7 on 2018-07-31 12:43
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.validators
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0097_auto_20180722_0804'),
]
operations = [
migrations.AlterModelOptions(
name='logentry',
options={'ordering': ('-datetime', '-id')},
),
migrations.AlterField(
model_name='orderpayment',
name='fee',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='pretixbase.OrderFee'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.AlterField(
model_name='staffsession',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='staffsessionauditlog',
name='impersonating',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='staffsessionauditlog',
name='session',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='logs', to='pretixbase.StaffSession'),
),
migrations.AlterField(
model_name='user',
name='locale',
field=models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)'), ('nl', 'Dutch'), ('da', 'Danish'), ('tr', 'Turkish'), ('pt-br', 'Portuguese (Brazil)')], default='en', max_length=50, verbose_name='Language'),
),
migrations.AlterUniqueTogether(
name='event',
unique_together={('organizer', 'slug')},
),
]

View File

@@ -0,0 +1,82 @@
# Generated by Django 2.0.8 on 2018-09-11 14:54
import django.core.validators
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
import pretix.base.validators
class Migration(migrations.Migration):
replaces = [('pretixbase', '0098_auto_20180731_1243'), ('pretixbase', '0099_auto_20180807_0841'), ('pretixbase', '0100_item_require_approval')]
dependencies = [
('pretixbase', '0097_auto_20180722_0804'),
]
operations = [
migrations.AlterModelOptions(
name='logentry',
options={'ordering': ('-datetime', '-id')},
),
migrations.AlterField(
model_name='orderpayment',
name='fee',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='payments', to='pretixbase.OrderFee'),
),
migrations.AlterField(
model_name='organizer',
name='slug',
field=models.SlugField(help_text='Should be short, only contain lowercase letters, numbers, dots, and dashes. Every slug can only be used once. This is being used in URLs to refer to your organizer accounts and your events.', unique=True, validators=[django.core.validators.RegexValidator(message='The slug may only contain letters, numbers, dots and dashes.', regex='^[a-zA-Z0-9.-]+$'), pretix.base.validators.OrganizerSlugBlacklistValidator()], verbose_name='Short form'),
),
migrations.AlterField(
model_name='staffsession',
name='user',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='staffsessionauditlog',
name='impersonating',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to=settings.AUTH_USER_MODEL),
),
migrations.AlterField(
model_name='staffsessionauditlog',
name='session',
field=models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='logs', to='pretixbase.StaffSession'),
),
migrations.AlterField(
model_name='user',
name='locale',
field=models.CharField(choices=[('en', 'English'), ('de', 'German'), ('de-informal', 'German (informal)'), ('nl', 'Dutch'), ('da', 'Danish'), ('tr', 'Turkish'), ('pt-br', 'Portuguese (Brazil)')], default='en', max_length=50, verbose_name='Language'),
),
migrations.AlterUniqueTogether(
name='event',
unique_together={('organizer', 'slug')},
),
migrations.AlterModelOptions(
name='waitinglistentry',
options={'ordering': ('-priority', 'created'), 'verbose_name': 'Waiting list entry', 'verbose_name_plural': 'Waiting list entries'},
),
migrations.AddField(
model_name='waitinglistentry',
name='priority',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='waitinglistentry',
name='voucher',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Voucher', verbose_name='Assigned voucher'),
),
migrations.AddField(
model_name='item',
name='require_approval',
field=models.BooleanField(default=False, help_text='If this product is part of an order, the order will be put into an "approval" state and will need to be confirmed by you before it can be paid and completed. You can use this e.g. for discounted tickets that are only available to specific groups.', verbose_name='Buying this product requires approval.'),
),
migrations.AddField(
model_name='order',
name='require_approval',
field=models.BooleanField(default=False),
),
]

View File

@@ -0,0 +1,28 @@
# Generated by Django 2.1 on 2018-08-07 08:41
import django.db.models.deletion
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0098_auto_20180731_1243'),
]
operations = [
migrations.AlterModelOptions(
name='waitinglistentry',
options={'ordering': ('-priority', 'created'), 'verbose_name': 'Waiting list entry', 'verbose_name_plural': 'Waiting list entries'},
),
migrations.AddField(
model_name='waitinglistentry',
name='priority',
field=models.IntegerField(default=0),
),
migrations.AlterField(
model_name='waitinglistentry',
name='voucher',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='waitinglistentries', to='pretixbase.Voucher', verbose_name='Assigned voucher'),
),
]

View File

@@ -0,0 +1,45 @@
# Generated by Django 2.1 on 2018-09-12 10:35
import django.db.models.deletion
from django.db import migrations, models
import pretix.base.models.devices
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0098_auto_20180731_1243_squashed_0100_item_require_approval'),
]
operations = [
migrations.CreateModel(
name='Device',
fields=[
('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
('device_id', models.PositiveIntegerField()),
('unique_serial', models.CharField(default=pretix.base.models.devices.generate_serial, max_length=190, unique=True)),
('initialization_token', models.CharField(default=pretix.base.models.devices.generate_initialization_token, max_length=190, unique=True)),
('api_token', models.CharField(max_length=190, null=True, unique=True)),
('all_events', models.BooleanField(default=False, verbose_name='All events (including newly created ones)')),
('name', models.CharField(max_length=190, verbose_name='Name')),
('created', models.DateTimeField(auto_now_add=True, verbose_name='Setup date')),
('initialized', models.DateTimeField(null=True, verbose_name='Initialization date')),
('hardware_brand', models.CharField(blank=True, max_length=190, null=True)),
('hardware_model', models.CharField(blank=True, max_length=190, null=True)),
('software_brand', models.CharField(blank=True, max_length=190, null=True)),
('software_version', models.CharField(blank=True, max_length=190, null=True)),
('limit_events', models.ManyToManyField(blank=True, to='pretixbase.Event', verbose_name='Limit to events')),
('organizer', models.ForeignKey(on_delete=django.db.models.deletion.PROTECT, related_name='devices', to='pretixbase.Organizer')),
],
),
migrations.AlterUniqueTogether(
name='device',
unique_together={('organizer', 'device_id')},
),
migrations.AddField(
model_name='logentry',
name='device',
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, to='pretixbase.Device'),
),
]

View File

@@ -0,0 +1,23 @@
# Generated by Django 2.1 on 2018-08-09 15:17
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('pretixbase', '0099_auto_20180807_0841'),
]
operations = [
migrations.AddField(
model_name='item',
name='require_approval',
field=models.BooleanField(default=False, help_text='If this product is part of an order, the order will be put into an "approval" state and will need to be confirmed by you before it can be paid and completed. You can use this e.g. for discounted tickets that are only available to specific groups.', verbose_name='Buying this product requires approval.'),
),
migrations.AddField(
model_name='order',
name='require_approval',
field=models.BooleanField(default=False),
),
]

View File

@@ -2,6 +2,7 @@ from ..settings import GlobalSettingsObject_SettingsStore
from .auth import U2FDevice, User
from .base import CachedFile, LoggedModel, cachedfile_name
from .checkin import Checkin, CheckinList
from .devices import Device
from .event import (
Event, Event_SettingsStore, EventLock, EventMetaProperty, EventMetaValue,
RequiredAction, SubEvent, SubEventMetaValue, generate_invite_token,
@@ -15,9 +16,9 @@ from .log import LogEntry
from .notifications import NotificationSetting
from .orders import (
AbstractPosition, CachedCombinedTicket, CachedTicket, CartPosition,
InvoiceAddress, Order, OrderPosition, QuestionAnswer,
cachedcombinedticket_name, cachedticket_name, generate_position_secret,
generate_secret,
InvoiceAddress, Order, OrderFee, OrderPayment, OrderPosition, OrderRefund,
QuestionAnswer, cachedcombinedticket_name, cachedticket_name,
generate_position_secret, generate_secret,
)
from .organizer import (
Organizer, Organizer_SettingsStore, Team, TeamAPIToken, TeamInvite,

View File

@@ -340,7 +340,7 @@ class User(AbstractBaseUser, PermissionsMixin, LoggingMixin):
class StaffSession(models.Model):
user = models.ForeignKey('User')
user = models.ForeignKey('User', on_delete=models.PROTECT)
date_start = models.DateTimeField(auto_now_add=True)
date_end = models.DateTimeField(null=True, blank=True)
session_key = models.CharField(max_length=255)
@@ -351,11 +351,11 @@ class StaffSession(models.Model):
class StaffSessionAuditLog(models.Model):
session = models.ForeignKey('StaffSession', related_name='logs')
session = models.ForeignKey('StaffSession', related_name='logs', on_delete=models.PROTECT)
datetime = models.DateTimeField(auto_now_add=True)
url = models.CharField(max_length=255)
method = models.CharField(max_length=255)
impersonating = models.ForeignKey('User', null=True, blank=True)
impersonating = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
class Meta:
ordering = ('datetime',)

View File

@@ -47,6 +47,7 @@ class LoggingMixin:
"""
from .log import LogEntry
from .event import Event
from .devices import Device
from pretix.api.models import OAuthAccessToken, OAuthApplication
from .organizer import TeamAPIToken
from ..notifications import get_all_notification_types
@@ -67,6 +68,8 @@ class LoggingMixin:
kwargs['oauth_application'] = auth
elif isinstance(auth, TeamAPIToken):
kwargs['api_token'] = auth
elif isinstance(auth, Device):
kwargs['device'] = auth
elif isinstance(api_token, TeamAPIToken):
kwargs['api_token'] = api_token
@@ -96,4 +99,4 @@ class LoggedModel(models.Model, LoggingMixin):
return LogEntry.objects.filter(
content_type=ContentType.objects.get_for_model(type(self)), object_id=self.pk
).select_related('user', 'event', 'oauth_application', 'api_token')
).select_related('user', 'event', 'oauth_application', 'api_token', 'device')

View File

@@ -8,12 +8,12 @@ from pretix.base.models import LoggedModel
class CheckinList(LoggedModel):
event = models.ForeignKey('Event', related_name='checkin_lists')
event = models.ForeignKey('Event', related_name='checkin_lists', on_delete=models.CASCADE)
name = models.CharField(max_length=190)
all_products = models.BooleanField(default=True, verbose_name=_("All products (including newly created ones)"))
limit_products = models.ManyToManyField('Item', verbose_name=_("Limit to products"), blank=True)
subevent = models.ForeignKey('SubEvent', null=True, blank=True,
verbose_name=pgettext_lazy('subevent', 'Date'))
verbose_name=pgettext_lazy('subevent', 'Date'), on_delete=models.CASCADE)
include_pending = models.BooleanField(verbose_name=pgettext_lazy('checkin', 'Include pending orders'),
default=False,
help_text=_('With this option, people will be able to check in even if the '
@@ -157,7 +157,7 @@ class Checkin(models.Model):
"""
A check-in object is created when a person enters the event.
"""
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins')
position = models.ForeignKey('pretixbase.OrderPosition', related_name='checkins', on_delete=models.CASCADE)
datetime = models.DateTimeField(default=now)
nonce = models.CharField(max_length=190, null=True, blank=True)
list = models.ForeignKey(

View File

@@ -0,0 +1,155 @@
import string
from django.db import models
from django.db.models import Max
from django.utils.crypto import get_random_string
from django.utils.translation import ugettext_lazy as _
from pretix.base.models import LoggedModel
def generate_serial():
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
while Device.objects.filter(unique_serial=serial).exists():
serial = get_random_string(allowed_chars='ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789', length=16)
return serial
def generate_initialization_token():
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(initialization_token=token).exists():
token = get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
return token
def generate_api_token():
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
while Device.objects.filter(api_token=token).exists():
token = get_random_string(length=64, allowed_chars=string.ascii_lowercase + string.digits)
return token
class Device(LoggedModel):
organizer = models.ForeignKey(
'pretixbase.Organizer',
on_delete=models.PROTECT,
related_name='devices'
)
device_id = models.PositiveIntegerField()
unique_serial = models.CharField(max_length=190, default=generate_serial, unique=True)
initialization_token = models.CharField(max_length=190, default=generate_initialization_token, unique=True)
api_token = models.CharField(max_length=190, unique=True, null=True)
all_events = models.BooleanField(default=False, verbose_name=_("All events (including newly created ones)"))
limit_events = models.ManyToManyField('Event', verbose_name=_("Limit to events"), blank=True)
name = models.CharField(
max_length=190,
verbose_name=_('Name')
)
created = models.DateTimeField(
auto_now_add=True,
verbose_name=_('Setup date')
)
initialized = models.DateTimeField(
verbose_name=_('Initialization date'),
null=True,
)
hardware_brand = models.CharField(
max_length=190,
null=True, blank=True
)
hardware_model = models.CharField(
max_length=190,
null=True, blank=True
)
software_brand = models.CharField(
max_length=190,
null=True, blank=True
)
software_version = models.CharField(
max_length=190,
null=True, blank=True
)
class Meta:
unique_together = (('organizer', 'device_id'),)
def __str__(self):
return '#{}: {} ({} {})'.format(
self.device_id, self.name, self.hardware_brand, self.hardware_model
)
def save(self, *args, **kwargs):
if not self.device_id:
self.device_id = (self.organizer.devices.aggregate(m=Max('device_id'))['m'] or 0) + 1
super().save(*args, **kwargs)
def permission_set(self) -> set:
return {
'can_view_orders',
'can_change_orders',
}
def get_event_permission_set(self, organizer, event) -> set:
"""
Gets a set of permissions (as strings) that a token holds for a particular event
:param organizer: The organizer of the event
:param event: The event to check
:return: set of permissions
"""
has_event_access = (self.all_events and organizer == self.organizer) or (
event in self.limit_events.all()
)
return self.permission_set() if has_event_access else set()
def get_organizer_permission_set(self, organizer) -> set:
"""
Gets a set of permissions (as strings) that a token holds for a particular organizer
:param organizer: The organizer of the event
:return: set of permissions
"""
return self.permission_set() if self.organizer == organizer else set()
def has_event_permission(self, organizer, event, perm_name=None, request=None) -> bool:
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the event ``event``.
:param organizer: The organizer of the event
:param event: The event to check
:param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
has_event_access = (self.all_events and organizer == self.organizer) or (
event in self.limit_events.all()
)
if isinstance(perm_name, (tuple, list)):
return has_event_access and any(p in self.permission_set() for p in perm_name)
return has_event_access and (not perm_name or perm_name in self.permission_set())
def has_organizer_permission(self, organizer, perm_name=None, request=None):
"""
Checks if this token is part of a team that grants access of type ``perm_name``
to the organizer ``organizer``.
:param organizer: The organizer to check
:param perm_name: The permission, e.g. ``can_change_teams``
:param request: This parameter is ignored and only defined for compatibility reasons.
:return: bool
"""
if isinstance(perm_name, (tuple, list)):
return organizer == self.organizer and any(p in self.permission_set() for p in perm_name)
return organizer == self.organizer and (not perm_name or perm_name in self.permission_set())
def get_events_with_any_permission(self):
"""
Returns a queryset of events the token has any permissions to.
:return: Iterable of Events
"""
if self.all_events:
return self.organizer.events.all()
else:
return self.limit_events.all()

View File

@@ -19,7 +19,6 @@ from django.utils.timezone import make_aware, now
from django.utils.translation import ugettext_lazy as _
from i18nfield.fields import I18nCharField, I18nTextField
from pretix.base.email import CustomSMTPBackend
from pretix.base.models.base import LoggedModel
from pretix.base.reldate import RelativeDateWrapper
from pretix.base.validators import EventSlugBlacklistValidator
@@ -265,6 +264,7 @@ class Event(EventMixin, LoggedModel):
verbose_name = _("Event")
verbose_name_plural = _("Events")
ordering = ("date_from", "name")
unique_together = (('organizer', 'slug'),)
def __str__(self):
return str(self.name)
@@ -276,12 +276,24 @@ class Event(EventMixin, LoggedModel):
else:
return super().presale_has_ended
def delete_all_orders(self, really=False):
from .orders import OrderRefund, OrderPayment, OrderPosition, OrderFee
if not really:
raise TypeError("Pass really=True as a parameter.")
OrderPosition.objects.all().delete(order__event=self)
OrderFee.objects.all().delete(order__event=self)
OrderPayment.objects.all().delete(order__event=self)
OrderRefund.objects.all().delete(order__event=self)
self.orders.all().delete()
def save(self, *args, **kwargs):
obj = super().save(*args, **kwargs)
self.cache.clear()
return obj
def get_plugins(self) -> "list[str]":
def get_plugins(self):
"""
Returns the names of the plugins activated for this event as a list.
"""
@@ -326,6 +338,8 @@ class Event(EventMixin, LoggedModel):
Returns an email server connection, either by using the system-wide connection
or by returning a custom one based on the event's settings.
"""
from pretix.base.email import CustomSMTPBackend
if self.settings.smtp_use_custom or force_custom:
return CustomSMTPBackend(host=self.settings.smtp_host,
port=self.settings.smtp_port,
@@ -450,10 +464,8 @@ class Event(EventMixin, LoggedModel):
if int(s.value) in tax_map:
s.value = tax_map.get(int(s.value)).pk
s.save()
else:
s.delete()
except ValueError:
s.delete()
pass
else:
s.save()
@@ -480,6 +492,31 @@ class Event(EventMixin, LoggedModel):
return OrderedDict(sorted(providers.items(), key=lambda v: str(v[1].verbose_name)))
def get_html_mail_renderer(self):
"""
Returns the currently selected HTML email renderer
"""
return self.get_html_mail_renderers()[
self.settings.mail_html_renderer
]
def get_html_mail_renderers(self) -> dict:
"""
Returns a dictionary of initialized HTML email renderers mapped by their identifiers.
"""
from ..signals import register_html_mail_renderers
responses = register_html_mail_renderers.send(self)
renderers = {}
for receiver, response in responses:
if not isinstance(response, list):
response = [response]
for p in response:
pp = p(self)
if pp.is_available:
renderers[pp.identifier] = pp
return renderers
def get_invoice_renderers(self) -> dict:
"""
Returns a dictionary of initialized invoice renderers mapped by their identifiers.
@@ -551,7 +588,12 @@ class Event(EventMixin, LoggedModel):
| Q(date_to__gte=now())
)
) # order_by doesn't make sense with I18nField
return sorted(subevs, key=attrgetter(*orderfields))
for f in reversed(orderfields):
if f.startswith('-'):
subevs = sorted(subevs, key=attrgetter(f[1:]), reverse=True)
else:
subevs = sorted(subevs, key=attrgetter(f))
return subevs
@property
def meta_data(self):
@@ -563,7 +605,7 @@ class Event(EventMixin, LoggedModel):
def has_payment_provider(self):
result = False
for provider in self.get_payment_providers().values():
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice'):
if provider.is_enabled and provider.identifier not in ('free', 'boxoffice', 'offsetting'):
result = True
break
return result

View File

@@ -64,14 +64,14 @@ class Invoice(models.Model):
:param file: The filename of the rendered invoice
:type file: File
"""
order = models.ForeignKey('Order', related_name='invoices', db_index=True)
order = models.ForeignKey('Order', related_name='invoices', db_index=True, on_delete=models.CASCADE)
organizer = models.ForeignKey('Organizer', related_name='invoices', db_index=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', related_name='invoices', db_index=True)
event = models.ForeignKey('Event', related_name='invoices', db_index=True, on_delete=models.CASCADE)
prefix = models.CharField(max_length=160, db_index=True)
invoice_no = models.CharField(max_length=19, db_index=True)
full_invoice_no = models.CharField(max_length=190, db_index=True)
is_cancellation = models.BooleanField(default=False)
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True)
refers = models.ForeignKey('Invoice', related_name='refered', null=True, blank=True, on_delete=models.CASCADE)
invoice_from = models.TextField()
invoice_to = models.TextField()
date = models.DateField(default=today)
@@ -175,7 +175,7 @@ class InvoiceLine(models.Model):
:param tax_name: The name of the applied tax rate
:type tax_name: str
"""
invoice = models.ForeignKey('Invoice', related_name='lines')
invoice = models.ForeignKey('Invoice', related_name='lines', on_delete=models.CASCADE)
position = models.PositiveIntegerField(default=0)
description = models.TextField()
gross_value = models.DecimalField(max_digits=10, decimal_places=2)

View File

@@ -193,6 +193,8 @@ class Item(LoggedModel):
:type checkin_attention: bool
:param original_price: The item's "original" price. Will not be used for any calculations, will just be shown.
:type original_price: decimal.Decimal
:param require_approval: If set to ``True``, orders containing this product can only be processed and paid after approved by an administrator
:type require_approval: bool
"""
event = models.ForeignKey(
@@ -280,6 +282,13 @@ class Item(LoggedModel):
help_text=_('To buy this product, the user needs a voucher that applies to this product '
'either directly or via a quota.')
)
require_approval = models.BooleanField(
verbose_name=_('Buying this product requires approval'),
default=False,
help_text=_('If this product is part of an order, the order will be put into an "approval" state and '
'will need to be confirmed by you before it can be paid and completed. You can use this e.g. for '
'discounted tickets that are only available to specific groups.'),
)
hide_without_voucher = models.BooleanField(
verbose_name=_('This product will only be shown if a voucher matching the product is redeemed.'),
default=False,
@@ -447,7 +456,8 @@ class ItemVariation(models.Model):
"""
item = models.ForeignKey(
Item,
related_name='variations'
related_name='variations',
on_delete=models.CASCADE
)
value = I18nCharField(
max_length=255,
@@ -562,12 +572,14 @@ class ItemAddOn(models.Model):
"""
base_item = models.ForeignKey(
Item,
related_name='addons'
related_name='addons',
on_delete=models.CASCADE
)
addon_category = models.ForeignKey(
ItemCategory,
related_name='addon_to',
verbose_name=_('Category')
verbose_name=_('Category'),
on_delete=models.CASCADE
)
min_count = models.PositiveIntegerField(
default=0,
@@ -679,7 +691,8 @@ class Question(LoggedModel):
event = models.ForeignKey(
Event,
related_name="questions"
related_name="questions",
on_delete=models.CASCADE
)
question = I18nTextField(
verbose_name=_("Question")
@@ -831,7 +844,7 @@ class Question(LoggedModel):
class QuestionOption(models.Model):
question = models.ForeignKey('Question', related_name='options')
question = models.ForeignKey('Question', related_name='options', on_delete=models.CASCADE)
identifier = models.CharField(max_length=190)
answer = I18nCharField(verbose_name=_('Answer'))
position = models.IntegerField(default=0)

View File

@@ -41,6 +41,7 @@ class LogEntry(models.Model):
datetime = models.DateTimeField(auto_now_add=True, db_index=True)
user = models.ForeignKey('User', null=True, blank=True, on_delete=models.PROTECT)
api_token = models.ForeignKey('TeamAPIToken', null=True, blank=True, on_delete=models.PROTECT)
device = models.ForeignKey('Device', null=True, blank=True, on_delete=models.PROTECT)
oauth_application = models.ForeignKey('pretixapi.OAuthApplication', null=True, blank=True, on_delete=models.PROTECT)
event = models.ForeignKey('Event', null=True, blank=True, on_delete=models.SET_NULL)
action_type = models.CharField(max_length=255)
@@ -52,7 +53,7 @@ class LogEntry(models.Model):
all = models.Manager()
class Meta:
ordering = ('-datetime',)
ordering = ('-datetime', '-id')
def display(self):
from ..signals import logentry_display
@@ -66,10 +67,13 @@ class LogEntry(models.Model):
def display_object(self):
from . import Order, Voucher, Quota, Item, ItemCategory, Question, Event, TaxRule, SubEvent
if self.content_type.model_class() is Event:
return ''
try:
if self.content_type.model_class() is Event:
return ''
co = self.content_object
co = self.content_object
except:
return ''
a_map = None
a_text = None

View File

@@ -1,5 +1,6 @@
import copy
import json
import logging
import os
import string
from datetime import datetime, time, timedelta
@@ -9,8 +10,11 @@ from typing import Any, Dict, List, Union
import dateutil
import pytz
from django.conf import settings
from django.db import models
from django.db.models import F, Sum
from django.db import models, transaction
from django.db.models import (
Case, Exists, F, Max, OuterRef, Q, Subquery, Sum, Value, When,
)
from django.db.models.functions import Coalesce
from django.db.models.signals import post_delete
from django.dispatch import receiver
from django.urls import reverse
@@ -31,6 +35,8 @@ from .base import LoggedModel
from .event import Event, SubEvent
from .items import Item, ItemVariation, Question, QuestionOption, Quota
logger = logging.getLogger(__name__)
def generate_secret():
return get_random_string(length=16, allowed_chars=string.ascii_lowercase + string.digits)
@@ -76,18 +82,14 @@ class Order(LoggedModel):
:type datetime: datetime
:param expires: The date until this order has to be paid to guarantee the fulfillment
:type expires: datetime
:param payment_date: The date of the payment completion (null if not yet paid)
:type payment_date: datetime
:param payment_provider: The payment provider selected by the user
:type payment_provider: str
:param payment_info: Arbitrary information stored by the payment provider
:type payment_info: str
:param total: The total amount of the order, including the payment fee
:type total: decimal.Decimal
:param comment: An internal comment that will only be visible to staff, and never displayed to the user
:type comment: str
:param download_reminder_sent: A field to indicate whether a download reminder has been sent.
:type download_reminder_sent: boolean
:param require_approval: If set to ``True``, this order is pending approval by an organizer
:type require_approval: bool
:param meta_info: Additional meta information on the order, JSON-encoded.
:type meta_info: str
"""
@@ -119,7 +121,8 @@ class Order(LoggedModel):
event = models.ForeignKey(
Event,
verbose_name=_("Event"),
related_name="orders"
related_name="orders",
on_delete=models.CASCADE
)
email = models.EmailField(
null=True, blank=True,
@@ -136,23 +139,6 @@ class Order(LoggedModel):
expires = models.DateTimeField(
verbose_name=_("Expiration date")
)
payment_date = models.DateTimeField(
verbose_name=_("Payment date"),
null=True, blank=True
)
payment_provider = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_("Payment provider")
)
payment_info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
)
payment_manual = models.BooleanField(
verbose_name=_("Payment state was manually modified"),
default=False
)
total = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Total amount")
@@ -183,6 +169,9 @@ class Order(LoggedModel):
last_modified = models.DateTimeField(
auto_now=True, db_index=True
)
require_approval = models.BooleanField(
default=False
)
class Meta:
verbose_name = _("Order")
@@ -199,6 +188,84 @@ class Order(LoggedModel):
except TypeError:
return None
@property
def payment_refund_sum(self):
payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
refund_sum = self.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum
@property
def pending_sum(self):
total = self.total
if self.status in (Order.STATUS_REFUNDED, Order.STATUS_CANCELED):
total = 0
payment_sum = self.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
refund_sum = self.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return total - payment_sum + refund_sum
@classmethod
def annotate_overpayments(cls, qs):
payment_sum = OrderPayment.objects.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED),
order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s')
refund_sum = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED),
order=OuterRef('pk')
).order_by().values('order').annotate(s=Sum('amount')).values('s')
external_refund = OrderRefund.objects.filter(
state=OrderRefund.REFUND_STATE_EXTERNAL,
order=OuterRef('pk')
)
pending_refund = OrderRefund.objects.filter(
state__in=(OrderRefund.REFUND_STATE_CREATED, OrderRefund.REFUND_STATE_TRANSIT),
order=OuterRef('pk')
)
qs = qs.annotate(
payment_sum=Subquery(payment_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
refund_sum=Subquery(refund_sum, output_field=models.DecimalField(decimal_places=2, max_digits=10)),
has_external_refund=Exists(external_refund),
has_pending_refund=Exists(pending_refund),
).annotate(
pending_sum_t=F('total') - Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
pending_sum_rc=-1 * Coalesce(F('payment_sum'), 0) + Coalesce(F('refund_sum'), 0),
).annotate(
is_overpaid=Case(
When(~Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_t__lt=0),
then=Value('1')),
When(Q(status__in=(Order.STATUS_REFUNDED, Order.STATUS_CANCELED)) & Q(pending_sum_rc__lt=0),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
),
is_pending_with_full_payment=Case(
When(Q(status__in=(Order.STATUS_EXPIRED, Order.STATUS_PENDING)) & Q(pending_sum_t__lte=0)
& Q(require_approval=False),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
),
is_underpaid=Case(
When(Q(status=Order.STATUS_PAID) & Q(pending_sum_t__gt=0),
then=Value('1')),
default=Value('0'),
output_field=models.IntegerField()
)
)
return qs
@property
def full_code(self):
"""
@@ -353,6 +420,20 @@ class Order(LoggedModel):
dl_date = dl_date.datetime(self.event)
return dl_date
@property
def ticket_download_available(self):
return self.event.settings.ticket_download and (
self.event.settings.ticket_download_date is None
or now() > self.ticket_download_date
) and (
self.status == Order.STATUS_PAID
or (
(self.event.settings.ticket_download_pending or self.total == Decimal("0.00")) and
self.status == Order.STATUS_PENDING and
not self.require_approval
)
)
@property
def payment_term_last(self):
tz = pytz.timezone(self.event.settings.timezone)
@@ -377,7 +458,10 @@ class Order(LoggedModel):
"payment settings is over."),
'late': _("The payment can not be accepted as it the order is expired and you configured that no late "
"payments should be accepted in the payment settings."),
'require_approval': _('This order is not yet approved by the event organizer.')
}
if self.require_approval:
return error_messages['require_approval']
term_last = self.payment_term_last
if term_last:
if now() > term_last:
@@ -425,7 +509,8 @@ class Order(LoggedModel):
def send_mail(self, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, log_entry_type: str='pretix.event.order.email.sent',
user: User=None, headers: dict=None, sender: str=None, invoices: list=None):
user: User=None, headers: dict=None, sender: str=None, invoices: list=None,
auth=None, attach_tickets=False):
"""
Sends an email to the user that placed this order. Basically, this method does two things:
@@ -441,6 +526,7 @@ class Order(LoggedModel):
:param user: Administrative user who triggered this mail to be sent
:param headers: Dictionary with additional mail headers
:param sender: Custom email sender.
:param attach_tickets: Attach tickets of this order, if they are existing and ready to download
"""
from pretix.base.services.mail import SendMailException, mail, render_mail
@@ -450,11 +536,11 @@ class Order(LoggedModel):
with language(self.locale):
recipient = self.email
try:
email_content = render_mail(template, context)[0]
email_content = render_mail(template, context)
mail(
recipient, subject, template, context,
self.event, self.locale, self, headers, sender,
invoices=invoices
invoices=invoices, attach_tickets=attach_tickets
)
except SendMailException:
raise
@@ -462,6 +548,7 @@ class Order(LoggedModel):
self.log_action(
log_entry_type,
user=user,
auth=auth,
data={
'subject': subject,
'message': email_content,
@@ -499,14 +586,14 @@ class QuestionAnswer(models.Model):
"""
orderposition = models.ForeignKey(
'OrderPosition', null=True, blank=True,
related_name='answers'
related_name='answers', on_delete=models.CASCADE
)
cartposition = models.ForeignKey(
'CartPosition', null=True, blank=True,
related_name='answers'
related_name='answers', on_delete=models.CASCADE
)
question = models.ForeignKey(
Question, related_name='answers'
Question, related_name='answers', on_delete=models.CASCADE
)
options = models.ManyToManyField(
QuestionOption, related_name='answers', blank=True
@@ -654,7 +741,7 @@ class AbstractPosition(models.Model):
help_text=_("Empty, if this product is not an admission ticket")
)
voucher = models.ForeignKey(
'Voucher', null=True, blank=True
'Voucher', null=True, blank=True, on_delete=models.CASCADE
)
addon_to = models.ForeignKey(
'self', null=True, blank=True, on_delete=models.CASCADE, related_name='addons'
@@ -711,10 +798,451 @@ class AbstractPosition(models.Model):
else self.variation.quotas.filter(subevent=self.subevent))
class OrderPayment(models.Model):
"""
Represents a payment or payment attempt for an order.
:param id: A globally unique ID for this payment
:type id:
:param local_id: An ID of this payment, counting from one for every order independently.
:type local_id: int
:param state: The state of the payment, one of ``created``, ``pending``, ``confirmed``, ``failed``,
``canceled``, or ``refunded``.
:type state: str
:param amount: The payment amount
:type amount: Decimal
:param order: The order that is paid
:type order: Order
:param created: The creation time of this record
:type created: datetime
:param payment_date: The completion time of this payment
:type payment_date: datetime
:param provider: The payment provider in use
:type provider: str
:param info: Provider-specific meta information (in JSON format)
:type info: str
:param fee: The ``OrderFee`` object used to track the fee for this order.
:type fee: pretix.base.models.OrderFee
"""
PAYMENT_STATE_CREATED = 'created'
PAYMENT_STATE_PENDING = 'pending'
PAYMENT_STATE_CONFIRMED = 'confirmed'
PAYMENT_STATE_FAILED = 'failed'
PAYMENT_STATE_CANCELED = 'canceled'
PAYMENT_STATE_REFUNDED = 'refunded'
PAYMENT_STATES = (
(PAYMENT_STATE_CREATED, pgettext_lazy('payment_state', 'created')),
(PAYMENT_STATE_PENDING, pgettext_lazy('payment_state', 'pending')),
(PAYMENT_STATE_CONFIRMED, pgettext_lazy('payment_state', 'confirmed')),
(PAYMENT_STATE_CANCELED, pgettext_lazy('payment_state', 'canceled')),
(PAYMENT_STATE_FAILED, pgettext_lazy('payment_state', 'failed')),
(PAYMENT_STATE_REFUNDED, pgettext_lazy('payment_state', 'refunded')),
)
local_id = models.PositiveIntegerField()
state = models.CharField(
max_length=190, choices=PAYMENT_STATES
)
amount = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Amount")
)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='payments',
on_delete=models.PROTECT
)
created = models.DateTimeField(
auto_now_add=True
)
payment_date = models.DateTimeField(
null=True, blank=True
)
provider = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_("Payment provider")
)
info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
)
fee = models.ForeignKey(
'OrderFee',
null=True, blank=True, related_name='payments', on_delete=models.SET_NULL
)
migrated = models.BooleanField(default=False)
class Meta:
ordering = ('local_id',)
@property
def info_data(self):
"""
This property allows convenient access to the data stored in the ``info``
attribute by automatically encoding and decoding the content as JSON.
"""
return json.loads(self.info) if self.info else {}
@info_data.setter
def info_data(self, d):
self.info = json.dumps(d)
@cached_property
def payment_provider(self):
"""
Cached access to an instance of the payment provider in use.
"""
return self.order.event.get_payment_providers().get(self.provider)
def _mark_paid(self, force, count_waitinglist, user, auth):
from pretix.base.signals import order_paid
can_be_paid = self.order._can_be_paid(count_waitinglist=count_waitinglist)
if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid)
self.order.status = Order.STATUS_PAID
self.order.save()
self.order.log_action('pretix.event.order.paid', {
'provider': self.provider,
'info': self.info,
'date': self.payment_date,
'force': force
}, user=user, auth=auth)
order_paid.send(self.order.event, order=self.order)
def confirm(self, count_waitinglist=True, send_mail=True, force=False, user=None, auth=None, mail_text=''):
"""
Marks the payment as complete. If possible, this also marks the order as paid if no further
payment is required
:param count_waitinglist: Whether, when calculating quota, people on the waiting list should be taken into
consideration (default: ``True``).
:type count_waitinglist: boolean
:param force: Whether this payment should be marked as paid even if no remaining
quota is available (default: ``False``).
:type force: boolean
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
:type send_mail: boolean
:param user: The user who performed the change
:param auth: The API auth token that performed the change
:param mail_text: Additional text to be included in the email
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
"""
from pretix.base.services.invoices import generate_invoice, invoice_qualified
from pretix.base.services.mail import SendMailException
from pretix.multidomain.urlreverse import build_absolute_uri
self.state = self.PAYMENT_STATE_CONFIRMED
self.payment_date = now()
self.save()
self.order.log_action('pretix.event.order.payment.confirmed', {
'local_id': self.local_id,
'provider': self.provider,
}, user=user, auth=auth)
if self.order.status == Order.STATUS_PAID:
return
payment_sum = self.order.payments.filter(
state__in=(self.PAYMENT_STATE_CONFIRMED, self.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
refund_sum = self.order.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
if payment_sum - refund_sum < self.order.total:
return
if self.order.status == Order.STATUS_PENDING and self.order.expires > now() + timedelta(hours=12):
# Performance optimization. In this case, there's really no reason to lock everything and an atomic
# database transaction is more than enough.
with transaction.atomic():
self._mark_paid(force, count_waitinglist, user, auth)
else:
with self.order.event.lock():
self._mark_paid(force, count_waitinglist, user, auth)
invoice = None
if invoice_qualified(self.order):
invoices = self.order.invoices.filter(is_cancellation=False).count()
cancellations = self.order.invoices.filter(is_cancellation=True).count()
gen_invoice = (
(invoices == 0 and self.order.event.settings.get('invoice_generate') in ('True', 'paid')) or
0 < invoices <= cancellations
)
if gen_invoice:
invoice = generate_invoice(
self.order,
trigger_pdf=not send_mail or not self.order.event.settings.invoice_email_attachment
)
if send_mail:
with language(self.order.locale):
try:
invoice_name = self.order.invoice_address.name
invoice_company = self.order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = self.order.event.settings.mail_text_order_paid
email_context = {
'event': self.order.event.name,
'url': build_absolute_uri(self.order.event, 'presale:event.order', kwargs={
'order': self.order.code,
'secret': self.order.secret
}),
'downloads': self.order.event.settings.get('ticket_download', as_type=bool),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
'payment_info': mail_text
}
email_subject = _('Payment received for your order: %(code)s') % {'code': self.order.code}
try:
self.order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and self.order.event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order paid email could not be sent')
@property
def refunded_amount(self):
"""
The sum of all refund amounts in ``done``, ``transit``, or ``created`` states associated
with this payment.
"""
return self.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT,
OrderRefund.REFUND_STATE_CREATED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
@property
def full_id(self):
"""
The full human-readable ID of this payment, constructed by the order code and the ``local_id``
field with ``-P-`` in between.
:return:
"""
return '{}-P-{}'.format(self.order.code, self.local_id)
def save(self, *args, **kwargs):
if not self.local_id:
self.local_id = (self.order.payments.aggregate(m=Max('local_id'))['m'] or 0) + 1
super().save(*args, **kwargs)
def create_external_refund(self, amount=None, execution_date=None, info='{}'):
"""
This should be called to create an OrderRefund object when a refund has triggered
by an external source, e.g. when a credit card payment has been refunded by the
credit card provider.
:param amount: Amount to refund. If not given, the full payment amount will be used.
:type amount: Decimal
:param execution_date: Date of the refund. Defaults to the current time.
:type execution_date: datetime
:param info: Additional information, defaults to ``"{}"``.
:type info: str
:return: OrderRefund
"""
r = self.order.refunds.create(
state=OrderRefund.REFUND_STATE_EXTERNAL,
source=OrderRefund.REFUND_SOURCE_EXTERNAL,
amount=amount if amount is not None else self.amount,
order=self.order,
payment=self,
execution_date=execution_date or now(),
provider=self.provider,
info=info
)
self.order.log_action('pretix.event.order.refund.created.externally', {
'local_id': r.local_id,
'provider': r.provider,
})
return r
class OrderRefund(models.Model):
"""
Represents a refund or refund attempt for an order.
:param id: A globally unique ID for this refund
:type id:
:param local_id: An ID of this refund, counting from one for every order independently.
:type local_id: int
:param state: The state of the refund, one of ``created``, ``transit``, ``external``, ``canceled``,
``failed``, or ``done``.
:type state: str
:param source: How this refund was started, one of ``buyer``, ``admin``, or ``external``.
:param amount: The refund amount
:type amount: Decimal
:param order: The order that is refunded
:type order: Order
:param created: The creation time of this record
:type created: datetime
:param execution_date: The completion time of this refund
:type execution_date: datetime
:param provider: The payment provider in use
:type provider: str
:param info: Provider-specific meta information in JSON format
:type info: dict
"""
# REFUND_STATE_REQUESTED = 'requested'
# REFUND_STATE_APPROVED = 'approved'
REFUND_STATE_EXTERNAL = 'external'
REFUND_STATE_TRANSIT = 'transit'
REFUND_STATE_DONE = 'done'
# REFUND_STATE_REJECTED = 'rejected'
REFUND_STATE_CANCELED = 'canceled'
REFUND_STATE_CREATED = 'created'
REFUND_STATE_FAILED = 'failed'
REFUND_STATES = (
# (REFUND_STATE_REQUESTED, pgettext_lazy('refund_state', 'requested')),
# (REFUND_STATE_APPROVED, pgettext_lazy('refund_state', 'approved')),
(REFUND_STATE_EXTERNAL, pgettext_lazy('refund_state', 'started externally')),
(REFUND_STATE_CREATED, pgettext_lazy('refund_state', 'created')),
(REFUND_STATE_TRANSIT, pgettext_lazy('refund_state', 'in transit')),
(REFUND_STATE_DONE, pgettext_lazy('refund_state', 'done')),
(REFUND_STATE_FAILED, pgettext_lazy('refund_state', 'failed')),
# (REFUND_STATE_REJECTED, pgettext_lazy('refund_state', 'rejected')),
(REFUND_STATE_CANCELED, pgettext_lazy('refund_state', 'canceled')),
)
REFUND_SOURCE_BUYER = 'buyer'
REFUND_SOURCE_ADMIN = 'admin'
REFUND_SOURCE_EXTERNAL = 'external'
REFUND_SOURCES = (
(REFUND_SOURCE_ADMIN, pgettext_lazy('refund_source', 'Organizer')),
(REFUND_SOURCE_BUYER, pgettext_lazy('refund_source', 'Customer')),
(REFUND_SOURCE_EXTERNAL, pgettext_lazy('refund_source', 'External')),
)
local_id = models.PositiveIntegerField()
state = models.CharField(
max_length=190, choices=REFUND_STATES
)
source = models.CharField(
max_length=190, choices=REFUND_SOURCES
)
amount = models.DecimalField(
decimal_places=2, max_digits=10,
verbose_name=_("Amount")
)
order = models.ForeignKey(
Order,
verbose_name=_("Order"),
related_name='refunds',
on_delete=models.PROTECT
)
payment = models.ForeignKey(
OrderPayment,
null=True, blank=True,
related_name='refunds',
on_delete=models.PROTECT
)
created = models.DateTimeField(
auto_now_add=True
)
execution_date = models.DateTimeField(
null=True, blank=True
)
provider = models.CharField(
null=True, blank=True,
max_length=255,
verbose_name=_("Payment provider")
)
info = models.TextField(
verbose_name=_("Payment information"),
null=True, blank=True
)
class Meta:
ordering = ('local_id',)
@property
def info_data(self):
"""
This property allows convenient access to the data stored in the ``info``
attribute by automatically encoding and decoding the content as JSON.
"""
return json.loads(self.info) if self.info else {}
@info_data.setter
def info_data(self, d):
self.info = json.dumps(d)
@cached_property
def payment_provider(self):
"""
Cached access to an instance of the payment provider in use.
"""
return self.order.event.get_payment_providers().get(self.provider)
@transaction.atomic
def done(self, user=None, auth=None):
"""
Marks the refund as complete. This does not modify the state of the order.
:param user: The user who performed the change
:param user: The API auth token that performed the change
"""
self.state = self.REFUND_STATE_DONE
self.execution_date = self.execution_date or now()
self.save()
self.order.log_action('pretix.event.order.refund.done', {
'local_id': self.local_id,
'provider': self.provider,
}, user=user, auth=auth)
if self.payment and self.payment.refunded_amount >= self.payment.amount:
self.payment.state = OrderPayment.PAYMENT_STATE_REFUNDED
self.payment.save(update_fields=['state'])
@property
def full_id(self):
"""
The full human-readable ID of this refund, constructed by the order code and the ``local_id``
field with ``-R-`` in between.
:return:
"""
return '{}-R-{}'.format(self.order.code, self.local_id)
def save(self, *args, **kwargs):
if not self.local_id:
self.local_id = (self.order.refunds.aggregate(m=Max('local_id'))['m'] or 0) + 1
super().save(*args, **kwargs)
class OrderFee(models.Model):
"""
An OrderFee objet represents a fee that is added to the order total independently of
An OrderFee object represents a fee that is added to the order total independently of
the actual positions. This might for example be a payment or a shipping fee.
:param value: Gross price of this fee
:type value: Decimal
:param order: Order this fee is charged with
:type order: Order
:param fee_type: The type of the fee, currently ``payment``, ``shipping``, ``service``, ``giftcard``, or ``other``.
:type fee_type: str
:param description: A human-readable description of the fee
:type description: str
:param internal_type: An internal string to group fees by, e.g. the identifier string of a payment provider
:type internal_type: str
:param tax_rate: The tax rate applied to this fee
:type tax_rate: Decimal
:param tax_rule: The tax rule applied to this fee
:type tax_rule: TaxRule
:param tax_value: The tax amount included in the price
:type tax_value: Decimal
"""
FEE_TYPE_PAYMENT = "payment"
FEE_TYPE_SHIPPING = "shipping"
@@ -813,6 +1341,18 @@ class OrderPosition(AbstractPosition):
:param order: The order this position is a part of
:type order: Order
:param positionid: A local ID of this position, counted for each order individually
:type positionid: int
:param tax_rate: The tax rate applied to this position
:type tax_rate: Decimal
:param tax_rule: The tax rule applied to this position
:type tax_rule: TaxRule
:param tax_value: The tax amount included in the price
:type tax_value: Decimal
:param secret: The secret used for ticket QR codes
:type secret: str
:param pseudonymization_id: The QR code content for lead scanning
:type pseudonymization_id: str
"""
positionid = models.PositiveIntegerField(default=1)
order = models.ForeignKey(
@@ -956,7 +1496,8 @@ class CartPosition(AbstractPosition):
"""
event = models.ForeignKey(
Event,
verbose_name=_("Event")
verbose_name=_("Event"),
on_delete=models.CASCADE
)
cart_id = models.CharField(
max_length=255, null=True, blank=True, db_index=True,
@@ -1000,7 +1541,7 @@ class CartPosition(AbstractPosition):
class InvoiceAddress(models.Model):
last_modified = models.DateTimeField(auto_now=True)
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address')
order = models.OneToOneField(Order, null=True, blank=True, related_name='invoice_address', on_delete=models.CASCADE)
is_business = models.BooleanField(default=False, verbose_name=_('Business customer'))
company = models.CharField(max_length=255, blank=True, verbose_name=_('Company name'))
name = models.CharField(max_length=255, verbose_name=_('Full name'), blank=True)

View File

@@ -42,6 +42,7 @@ class Organizer(LoggedModel):
OrganizerSlugBlacklistValidator()
],
verbose_name=_("Short form"),
unique=True
)
class Meta:
@@ -57,7 +58,7 @@ class Organizer(LoggedModel):
self.get_cache().clear()
return obj
def get_cache(self) -> "pretix.base.cache.ObjectRelatedCache":
def get_cache(self):
"""
Returns an :py:class:`ObjectRelatedCache` object. This behaves equivalent to
Django's built-in cache backends, but puts you into an isolated environment for

View File

@@ -60,7 +60,7 @@ EU_CURRENCIES = {
class TaxRule(LoggedModel):
event = models.ForeignKey('Event', related_name='tax_rules')
event = models.ForeignKey('Event', related_name='tax_rules', on_delete=models.CASCADE)
name = I18nCharField(
verbose_name=_('Name'),
help_text=_('Should be short, e.g. "VAT"'),

View File

@@ -137,14 +137,14 @@ class Voucher(LoggedModel):
item = models.ForeignKey(
Item, related_name='vouchers',
verbose_name=_("Product"),
null=True, blank=True,
null=True, blank=True, on_delete=models.CASCADE,
help_text=_(
"This product is added to the user's cart if the voucher is redeemed."
)
)
variation = models.ForeignKey(
ItemVariation, related_name='vouchers',
null=True, blank=True,
null=True, blank=True, on_delete=models.CASCADE,
verbose_name=_("Product variation"),
help_text=_(
"This variation of the product select above is being used."
@@ -152,7 +152,7 @@ class Voucher(LoggedModel):
)
quota = models.ForeignKey(
Quota, related_name='quota',
null=True, blank=True,
null=True, blank=True, on_delete=models.CASCADE,
verbose_name=_("Quota"),
help_text=_(
"If enabled, the voucher is valid for any product affected by this quota."

View File

@@ -42,10 +42,12 @@ class WaitingListEntry(LoggedModel):
voucher = models.ForeignKey(
'Voucher',
verbose_name=_("Assigned voucher"),
null=True, blank=True
null=True, blank=True,
related_name='waitinglistentries',
on_delete=models.CASCADE
)
item = models.ForeignKey(
Item, related_name='waitinglistentries',
Item, related_name='waitinglistentries', on_delete=models.CASCADE,
verbose_name=_("Product"),
help_text=_(
"The product the user waits for."
@@ -53,7 +55,7 @@ class WaitingListEntry(LoggedModel):
)
variation = models.ForeignKey(
ItemVariation, related_name='waitinglistentries',
null=True, blank=True,
null=True, blank=True, on_delete=models.CASCADE,
verbose_name=_("Product variation"),
help_text=_(
"The variation of the product selected above."
@@ -63,11 +65,12 @@ class WaitingListEntry(LoggedModel):
max_length=190,
default='en'
)
priority = models.IntegerField(default=0)
class Meta:
verbose_name = _("Waiting list entry")
verbose_name_plural = _("Waiting list entries")
ordering = ['created']
ordering = ('-priority', 'created')
def __str__(self):
return '%s waits for %s' % (str(self.email), str(self.item))

View File

@@ -229,6 +229,12 @@ def register_default_notification_types(sender, **kwargs):
_('Order changed'),
_('Order {order.code} has been changed.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.refund.created.externally',
_('External refund of payment'),
_('An external refund for {order.code} has occurred.')
),
ParametrizedOrderNotificationType(
sender,
'pretix.event.order.refunded',

View File

@@ -1,3 +1,4 @@
import json
import logging
from collections import OrderedDict
from decimal import ROUND_HALF_UP, Decimal
@@ -6,23 +7,30 @@ from typing import Any, Dict, Union
import pytz
from django import forms
from django.conf import settings
from django.contrib import messages
from django.core.exceptions import ImproperlyConfigured
from django.dispatch import receiver
from django.forms import Form
from django.http import HttpRequest
from django.template.loader import get_template
from django.utils.timezone import now
from django.utils.translation import pgettext_lazy, ugettext_lazy as _
from i18nfield.forms import I18nFormField, I18nTextarea
from django_countries import Countries
from i18nfield.forms import I18nFormField, I18nTextarea, I18nTextInput
from i18nfield.strings import LazyI18nString
from pretix.base.models import CartPosition, Event, Order, Quota
from pretix.base.forms import PlaceholderValidator
from pretix.base.models import (
CartPosition, Event, InvoiceAddress, Order, OrderPayment, OrderRefund,
Quota,
)
from pretix.base.reldate import RelativeDateField, RelativeDateWrapper
from pretix.base.settings import SettingsSandbox
from pretix.base.signals import register_payment_providers
from pretix.base.templatetags.money import money_filter
from pretix.base.templatetags.rich_text import rich_text
from pretix.helpers.money import DecimalTextInput
from pretix.presale.views import get_cart_total
from pretix.presale.views.cart import get_or_create_cart_id
from pretix.presale.views.cart import cart_session, get_or_create_cart_id
logger = logging.getLogger(__name__)
@@ -130,6 +138,16 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
@property
def abort_pending_allowed(self) -> bool:
"""
Whether or not a user can abort a payment in pending start to switch to another
payment method. This returns ``False`` by default which is no guarantee that
aborting a pending payment can never happen, it just hides the frontend button
to avoid users accidentally committing double payments.
"""
return False
@property
def settings_form_fields(self) -> dict:
"""
@@ -163,7 +181,7 @@ class BasePaymentProvider:
implementation.
"""
places = settings.CURRENCY_PLACES.get(self.event.currency, 2)
return OrderedDict([
d = OrderedDict([
('_enabled',
forms.BooleanField(
label=_('Enable payment method'),
@@ -185,6 +203,28 @@ class BasePaymentProvider:
widget=I18nTextarea,
widget_kwargs={'attrs': {'rows': '2'}}
)),
('_total_min',
forms.DecimalField(
label=_('Minimum order total'),
help_text=_('This payment will be available only if the order total is equal to or exceeds the given '
'value. The order total for this purpose may be computed without taking the fees imposed '
'by this payment method into account.'),
localize=True,
required=False,
decimal_places=places,
widget=DecimalTextInput(places=places)
)),
('_total_max',
forms.DecimalField(
label=_('Maximum order total'),
help_text=_('This payment will be available only if the order total is equal to or below the given '
'value. The order total for this purpose may be computed without taking the fees imposed '
'by this payment method into account.'),
localize=True,
required=False,
decimal_places=places,
widget=DecimalTextInput(places=places)
)),
('_fee_abs',
forms.DecimalField(
label=_('Additional fee'),
@@ -212,7 +252,20 @@ class BasePaymentProvider:
'above!').format(docs_url='https://docs.pretix.eu/en/latest/user/payments/fees.html'),
required=False
)),
('_restricted_countries',
forms.MultipleChoiceField(
label=_('Restrict to countries'),
choices=Countries(),
help_text=_('Only allow choosing this payment provider for invoice addresses in the selected '
'countries. If you don\'t select any country, all countries are allowed.'),
widget=forms.CheckboxSelectMultiple(
attrs={'class': 'scrolling-multiple-choice'}
),
required=False
)),
])
d['_restricted_countries']._as_type = list
return d
def settings_content_render(self, request: HttpRequest) -> str:
"""
@@ -304,20 +357,60 @@ class BasePaymentProvider:
return True
def is_allowed(self, request: HttpRequest) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
"""
You can use this method to disable this payment provider for certain groups
of users, products or other criteria. If this method returns ``False``, the
user will not be able to select this payment method. This will only be called
during checkout, not on retrying.
The default implementation checks for the _availability_date setting to be either unset or in the future.
"""
return self._is_still_available(cart_id=get_or_create_cart_id(request))
The default implementation checks for the _availability_date setting to be either unset or in the future
and for the _total_max and _total_min requirements to be met. It also checks the ``_restrict_countries``
setting.
def payment_form_render(self, request: HttpRequest) -> str:
:param total: The total value without the payment method fee, after taxes.
.. versionchanged:: 1.17.0
The ``total`` parameter has been added. For backwards compatibility, this method is called again
without this parameter if it raises a ``TypeError`` on first try.
"""
When the user selects this provider as his preferred payment method,
timing = self._is_still_available(cart_id=get_or_create_cart_id(request))
pricing = True
if (self.settings._total_max is not None or self.settings._total_min is not None) and total is None:
raise ImproperlyConfigured('This payment provider does not support maximum or minimum amounts.')
if self.settings._total_max is not None:
pricing = pricing and total <= Decimal(self.settings._total_max)
if self.settings._total_min is not None:
pricing = pricing and total >= Decimal(self.settings._total_min)
def get_invoice_address():
if not hasattr(request, '_checkout_flow_invoice_address'):
cs = cart_session(request)
iapk = cs.get('invoice_address')
if not iapk:
request._checkout_flow_invoice_address = InvoiceAddress()
else:
try:
request._checkout_flow_invoice_address = InvoiceAddress.objects.get(pk=iapk, order__isnull=True)
except InvoiceAddress.DoesNotExist:
request._checkout_flow_invoice_address = InvoiceAddress()
return request._checkout_flow_invoice_address
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
if restricted_countries:
ia = get_invoice_address()
if str(ia.country) not in restricted_countries:
return False
return timing and pricing
def payment_form_render(self, request: HttpRequest, total: Decimal) -> str:
"""
When the user selects this provider as their preferred payment method,
they will be shown the HTML you return from this method.
The default implementation will call :py:meth:`checkout_form`
@@ -332,8 +425,8 @@ class BasePaymentProvider:
def checkout_confirm_render(self, request) -> str:
"""
If the user has successfully filled in his payment data, they will be redirected
to a confirmation page which lists all details of his order for a final review.
If the user has successfully filled in their payment data, they will be redirected
to a confirmation page which lists all details of their order for a final review.
This method should return the HTML which should be displayed inside the
'Payment' box on this page.
@@ -342,11 +435,19 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def payment_pending_render(self, request: HttpRequest, payment: OrderPayment) -> str:
"""
Render customer-facing instructions on how to proceed with a pending payment
:return: HTML
"""
return ""
def checkout_prepare(self, request: HttpRequest, cart: Dict[str, Any]) -> Union[bool, str]:
"""
Will be called after the user selects this provider as his payment method.
Will be called after the user selects this provider as their payment method.
If you provided a form to the user to enter payment data, this method should
at least store the user's input into his session.
at least store the user's input into their session.
This method should return ``False`` if the user's input was invalid, ``True``
if the input was valid and the frontend should continue with default behavior
@@ -361,7 +462,7 @@ class BasePaymentProvider:
If your payment method requires you to redirect the user to an external provider,
this might be the place to do so.
.. IMPORTANT:: If this is called, the user has not yet confirmed his or her order.
.. IMPORTANT:: If this is called, the user has not yet confirmed their order.
You may NOT do anything which actually moves money.
:param cart: This dictionary contains at least the following keys:
@@ -396,26 +497,29 @@ class BasePaymentProvider:
"""
raise NotImplementedError() # NOQA
def payment_perform(self, request: HttpRequest, order: Order) -> str:
def execute_payment(self, request: HttpRequest, payment: OrderPayment) -> str:
"""
After the user has confirmed their purchase, this method will be called to complete
the payment process. This is the place to actually move the money if applicable.
If you need any special behavior, you can return a string
You will be passed an :py:class:`pretix.base.models.OrderPayment` object that contains
the amount of money that should be paid.
If you need any special behavior, you can return a string
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.
If the payment is completed, you should call ``pretix.base.services.orders.mark_order_paid(order, provider, info)``
with ``provider`` being your :py:attr:`identifier` and ``info`` being any string
you might want to store for later usage. Please note that ``mark_order_paid`` might
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 to the user.
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
some of the items are sold out. You should use the exception message to display a meaningful error
to the user.
The default implementation just returns ``None`` and therefore leaves the
order unpaid. The user will be redirected to the order's detail page by default.
On errors, you should raise a ``PaymentException``.
:param order: The order object
:param payment: An ``OrderPayment`` instance
"""
return None
@@ -429,55 +533,36 @@ class BasePaymentProvider:
"""
return ""
def order_pending_render(self, request: HttpRequest, order: Order) -> str:
"""
If the user visits a detail page of an order which has not yet been paid but
this payment method was selected during checkout, this method will be called
to provide HTML content for the 'payment' box on the page.
It should contain instructions on how to continue with the payment process,
either in form of text or buttons/links/etc.
:param order: The order object
"""
raise NotImplementedError() # NOQA
def order_change_allowed(self, order: Order) -> bool:
"""
Will be called to check whether it is allowed to change the payment method of
an order to this one.
The default implementation checks for the _availability_date setting to be either unset or in the future.
The default implementation checks for the _availability_date setting to be either unset or in the future,
as well as for the _total_max, _total_min and _restricted_countries settings.
:param order: The order object
"""
ps = order.pending_sum
if self.settings._total_max is not None and ps > Decimal(self.settings._total_max):
return False
if self.settings._total_min is not None and ps < Decimal(self.settings._total_min):
return False
restricted_countries = self.settings.get('_restricted_countries', as_type=list)
if restricted_countries:
try:
ia = order.invoice_address
except InvoiceAddress.DoesNotExist:
return True
else:
if str(ia.country) not in restricted_countries:
return False
return self._is_still_available(order=order)
def order_can_retry(self, order: Order) -> bool:
"""
Will be called if the user views the detail page of an unpaid order to determine
whether the user should be presented with an option to retry the payment. The default
implementation always returns False.
If you want to enable retrials for your payment method, the best is to just return
``self._is_still_available()`` from this method to disable it as soon as the method
gets disabled or the methods end date is reached.
The retry workflow is also used if a user switches to this payment method for an existing
order!
:param order: The order object
"""
return False
def retry_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Deprecated, use order_prepare instead
"""
raise DeprecationWarning('retry_prepare is deprecated, use order_prepare instead')
return self.order_prepare(request, order)
def order_prepare(self, request: HttpRequest, order: Order) -> Union[bool, str]:
def payment_prepare(self, request: HttpRequest, payment: OrderPayment) -> Union[bool, str]:
"""
Will be called if the user retries to pay an unpaid order (after the user filled in
e.g. the form returned by :py:meth:`payment_form`) or if the user changes the payment
@@ -498,22 +583,9 @@ class BasePaymentProvider:
else:
return False
def order_paid_render(self, request: HttpRequest, order: Order) -> str:
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
"""
Will be called if the user views the detail page of a paid order which is
associated with this payment provider.
It should return HTML code which should be displayed to the user or None,
if there is nothing to say (like the default implementation does).
:param order: The order object
"""
return None
def order_control_render(self, request: HttpRequest, order: Order) -> str:
"""
Will be called if the *event administrator* views the detail page of an order
which is associated with this payment provider.
Will be called if the *event administrator* views the details of a payment.
It should return HTML code containing information regarding the current payment
status and, if applicable, next steps.
@@ -522,62 +594,44 @@ class BasePaymentProvider:
:param order: The order object
"""
return _('Payment provider: %s' % self.verbose_name)
return ''
def order_control_refund_render(self, order: Order, request: HttpRequest=None) -> str:
def payment_refund_supported(self, payment: OrderPayment) -> bool:
"""
Will be called if the event administrator clicks an order's 'refund' button.
This can be used to display information *before* the order is being refunded.
It should return HTML code which should be displayed to the user. It should
contain information about to which extend the money will be refunded
automatically.
:param order: The order object
:param request: The HTTP request
.. versionchanged:: 1.6
The parameter ``request`` has been added.
Will be called to check if the provider supports automatic refunding for this
payment.
"""
return '<div class="alert alert-warning">%s</div>' % _('The money can not be automatically refunded, '
'please transfer the money back manually.')
return False
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
def payment_partial_refund_supported(self, payment: OrderPayment) -> bool:
"""
Will be called if the event administrator confirms the refund.
This should transfer the money back (if possible). You can return the URL the
user should be redirected to if you need special behavior or None to continue
with default behavior.
On failure, you should use Django's message framework to display an error message
to the user.
The default implementation sets the Order's state to refunded and shows a success
message.
:param request: The HTTP request
:param order: The order object
Will be called to check if the provider supports automatic partial refunding for this
payment.
"""
from pretix.base.services.orders import mark_order_refunded
return False
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded. Please transfer the money '
'back to the buyer manually.'))
def execute_refund(self, refund: OrderRefund):
"""
Will be called to execute an refund. Note that refunds have an amount property and can be partial.
def shred_payment_info(self, order: Order):
This should transfer the money back (if possible).
On success, you should call ``refund.done()``.
On failure, you should raise a PaymentException.
"""
raise PaymentException(_('Automatic refunds are not supported by this payment provider.'))
def shred_payment_info(self, obj: Union[OrderPayment, OrderRefund]):
"""
When personal data is removed from an event, this method is called to scrub payment-related data
from an order. By default, it removes all info from the ``payment_info`` attribute. You can override
from a payment or refund. By default, it removes all info from the ``info`` attribute. You can override
this behavior if you want to retain attributes that are not personal data on their own, i.e. a
reference to a transaction in an external system. You can also override this to scrub more data, e.g.
data from external sources that is saved in LogEntry objects or other places.
:param order: An order
"""
order.payment_info = None
order.save(update_fields=['payment_info'])
obj.info = '{}'
obj.save(update_fields=['info'])
class PaymentException(Exception):
@@ -585,25 +639,13 @@ class PaymentException(Exception):
class FreeOrderProvider(BasePaymentProvider):
@property
def is_implicit(self) -> bool:
return True
@property
def is_enabled(self) -> bool:
return True
@property
def identifier(self) -> str:
return "free"
is_implicit = True
is_enabled = True
identifier = "free"
def checkout_confirm_render(self, request: HttpRequest) -> str:
return _("No payment is required as this order only includes products which are free of charge.")
def order_pending_render(self, request: HttpRequest, order: Order) -> str:
pass
def payment_is_valid_session(self, request: HttpRequest) -> bool:
return True
@@ -611,10 +653,9 @@ class FreeOrderProvider(BasePaymentProvider):
def verbose_name(self) -> str:
return _("Free of charge")
def payment_perform(self, request: HttpRequest, order: Order):
from pretix.base.services.orders import mark_order_paid
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
try:
mark_order_paid(order, 'free', send_mail=False)
payment.confirm(send_mail=False)
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
@@ -622,32 +663,7 @@ class FreeOrderProvider(BasePaymentProvider):
def settings_form_fields(self) -> dict:
return {}
def order_control_refund_render(self, order: Order) -> str:
return ''
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
"""
Will be called if the event administrator confirms the refund.
This should transfer the money back (if possible). You can return the URL the
user should be redirected to if you need special behavior or None to continue
with default behavior.
On failure, you should use Django's message framework to display an error message
to the user.
The default implementation sets the Order's state to refunded and shows a success
message.
:param request: The HTTP request
:param order: The order object
"""
from pretix.base.services.orders import mark_order_refunded
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded.'))
def is_allowed(self, request: HttpRequest) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
from .services.cart import get_fees
total = get_cart_total(request)
@@ -664,10 +680,9 @@ class BoxOfficeProvider(BasePaymentProvider):
identifier = "boxoffice"
verbose_name = _("Box office")
def payment_perform(self, request: HttpRequest, order: Order):
from pretix.base.services.orders import mark_order_paid
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
try:
mark_order_paid(order, 'boxoffice', send_mail=False)
payment.confirm(send_mail=False)
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
@@ -675,22 +690,139 @@ class BoxOfficeProvider(BasePaymentProvider):
def settings_form_fields(self) -> dict:
return {}
def order_control_refund_render(self, order: Order) -> str:
return ''
def order_control_refund_perform(self, request: HttpRequest, order: Order) -> Union[bool, str]:
from pretix.base.services.orders import mark_order_refunded
mark_order_refunded(order, user=request.user)
messages.success(request, _('The order has been marked as refunded.'))
def is_allowed(self, request: HttpRequest) -> bool:
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
return False
def order_change_allowed(self, order: Order) -> bool:
return False
class ManualPayment(BasePaymentProvider):
identifier = 'manual'
verbose_name = _('Manual payment')
@property
def is_implicit(self):
return 'pretix.plugins.manualpayment' not in self.event.plugins
def is_allowed(self, request: HttpRequest, total: Decimal=None):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().is_allowed(request, total)
def order_change_allowed(self, order: Order):
return 'pretix.plugins.manualpayment' in self.event.plugins and super().order_change_allowed(order)
@property
def public_name(self):
return str(self.settings.get('public_name', as_type=LazyI18nString))
@property
def settings_form_fields(self):
d = OrderedDict(
[
('public_name', I18nFormField(
label=_('Payment method name'),
widget=I18nTextInput,
)),
('checkout_description', I18nFormField(
label=_('Payment process description during checkout'),
help_text=_('This text will be shown during checkout when the user selects this payment method. '
'It should give a short explanation on this payment method.'),
widget=I18nTextarea,
)),
('email_instructions', I18nFormField(
label=_('Payment process description in order confirmation emails'),
help_text=_('This text will be included for the {payment_info} placeholder in order confirmation '
'mails. It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
('pending_description', I18nFormField(
label=_('Payment process description for pending orders'),
help_text=_('This text will be shown on the order confirmation page for pending orders. '
'It should instruct the user on how to proceed with the payment. You can use'
'the placeholders {order}, {total}, {currency} and {total_with_currency}'),
widget=I18nTextarea,
validators=[PlaceholderValidator(['{order}', '{total}', '{currency}', '{total_with_currency}'])],
)),
] + list(super().settings_form_fields.items())
)
d.move_to_end('_enabled', last=False)
return d
def payment_form_render(self, request) -> str:
return rich_text(
str(self.settings.get('checkout_description', as_type=LazyI18nString))
)
def checkout_prepare(self, request, total):
return True
def payment_is_valid_session(self, request):
return True
def checkout_confirm_render(self, request):
return self.payment_form_render(request)
def format_map(self, order):
return {
'order': order.code,
'total': order.total,
'currency': self.event.currency,
'total_with_currency': money_filter(order.total, self.event.currency)
}
def order_pending_mail_render(self, order) -> str:
msg = str(self.settings.get('email_instructions', as_type=LazyI18nString)).format_map(self.format_map(order))
return msg
def order_pending_render(self, request, order) -> str:
return rich_text(
str(self.settings.get('pending_description', as_type=LazyI18nString)).format_map(self.format_map(order))
)
class OffsettingProvider(BasePaymentProvider):
is_enabled = True
identifier = "offsetting"
verbose_name = _("Offsetting")
is_implicit = True
def execute_payment(self, request: HttpRequest, payment: OrderPayment):
try:
payment.confirm()
except Quota.QuotaExceededException as e:
raise PaymentException(str(e))
def execute_refund(self, refund: OrderRefund):
code = refund.info_data['orders'][0]
try:
order = Order.objects.get(code=code, event__organizer=self.event.organizer)
except Order.DoesNotExist:
raise PaymentException(_('You entered an order that could not be found.'))
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_PENDING,
amount=refund.amount,
payment_date=now(),
provider='offsetting',
info=json.dumps({'orders': [refund.order.code]})
)
p.confirm()
@property
def settings_form_fields(self) -> dict:
return {}
def is_allowed(self, request: HttpRequest, total: Decimal=None) -> bool:
return False
def order_change_allowed(self, order: Order) -> bool:
return False
def payment_control_render(self, request: HttpRequest, payment: OrderPayment) -> str:
return _('Balanced against orders: %s' % ', '.join(payment.info_data['orders']))
@receiver(register_payment_providers, dispatch_uid="payment_free")
def register_payment_provider(sender, **kwargs):
return [FreeOrderProvider, BoxOfficeProvider]
return [FreeOrderProvider, BoxOfficeProvider, OffsettingProvider, ManualPayment]

View File

@@ -24,6 +24,7 @@ from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfgen.canvas import Canvas
from reportlab.platypus import Paragraph
from pretix.base.invoice import ThumbnailingImageReader
from pretix.base.models import Order, OrderPosition
from pretix.base.signals import layout_text_variables
from pretix.base.templatetags.money import money_filter
@@ -160,7 +161,10 @@ DEFAULT_VARIABLES = OrderedDict((
"editor_sample": _("Addon 1\nAddon 2"),
"evaluate": lambda op, order, ev: "<br/>".join([
'{} - {}'.format(p.item, p.variation) if p.variation else str(p.item)
for p in op.addons.select_related('item', 'variation')
for p in (
op.addons.all() if 'addons' in getattr(op, '_prefetched_objects_cache', {})
else op.addons.select_related('item', 'variation')
)
])
}),
("organizer", {
@@ -210,6 +214,22 @@ class Renderer:
if 'bolditalic' in styles:
pdfmetrics.registerFont(TTFont(family + ' B I', finders.find(styles['bolditalic']['truetype'])))
def _draw_poweredby(self, canvas: Canvas, op: OrderPosition, o: dict):
content = o.get('content', 'dark')
img = finders.find('pretixpresale/pdf/powered_by_pretix_{}.png'.format(content))
ir = ThumbnailingImageReader(img)
try:
width, height = ir.resize(None, float(o['size']) * mm, 300)
except:
logger.exception("Can not resize image")
pass
canvas.drawImage(ir,
float(o['left']) * mm, float(o['bottom']) * mm,
width=width, height=height,
preserveAspectRatio=True, anchor='n',
mask='auto')
def _draw_barcodearea(self, canvas: Canvas, op: OrderPosition, o: dict):
content = o.get('content', 'secret')
if content == 'secret':
@@ -284,6 +304,8 @@ class Renderer:
self._draw_barcodearea(canvas, op, o)
elif o['type'] == "textarea":
self._draw_textarea(canvas, op, order, o)
elif o['type'] == "poweredby":
self._draw_poweredby(canvas, op, o)
canvas.showPage()
def render_background(self, buffer, title=_('Ticket')):

View File

@@ -2,6 +2,7 @@ from enum import Enum
from typing import List
from django.apps import apps
from django.conf import settings
class PluginType(Enum):
@@ -26,5 +27,7 @@ def get_all_plugins() -> List[type]:
meta = app.PretixPluginMeta
meta.module = app.name
meta.app = app
if app.name in settings.PRETIX_PLUGINS_EXCLUDE:
continue
plugins.append(meta)
return plugins

View File

@@ -104,7 +104,7 @@ class RelativeDateWrapper:
timeparts = parts[2].split(':')
time = datetime.time(hour=int(timeparts[0]), minute=int(timeparts[1]), second=int(timeparts[2]))
data = RelativeDate(
days_before=int(parts[1]),
days_before=int(parts[1] or 0),
base_date_name=parts[3],
time=time
)

View File

@@ -17,9 +17,9 @@ from pretix.base.models import (
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import OrderFee
from pretix.base.models.tax import TAXED_ZERO, TaxedPrice, TaxRule
from pretix.base.services.async import ProfiledTask
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.templatetags.rich_text import rich_text
from pretix.celery_app import app
from pretix.presale.signals import (
@@ -64,6 +64,10 @@ error_messages = {
'price_too_high': _('The entered price is to high.'),
'voucher_invalid': _('This voucher code is not known in our database.'),
'voucher_redeemed': _('This voucher code has already been used the maximum number of times allowed.'),
'voucher_redeemed_cart': _('This voucher code is currently locked since it is already contained in a cart. This '
'might mean that someone else is redeeming this voucher right now, or that you tried '
'to redeem it before but did not complete the checkout process. You can try to use it '
'again in %d minutes.'),
'voucher_redeemed_partial': _('This voucher code can only be redeemed %d more times.'),
'voucher_double': _('You already used this voucher code. Remove the associated line from your '
'cart if you want to use it for a different product.'),
@@ -232,11 +236,17 @@ class CartManager:
def _get_price(self, item: Item, variation: Optional[ItemVariation],
voucher: Optional[Voucher], custom_price: Optional[Decimal],
subevent: Optional[SubEvent], cp_is_net: bool=None):
return get_price(
item, variation, voucher, custom_price, subevent,
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
invoice_address=self.invoice_address
)
try:
return get_price(
item, variation, voucher, custom_price, subevent,
custom_price_is_net=cp_is_net if cp_is_net is not None else self.event.settings.display_net_prices,
invoice_address=self.invoice_address
)
except ValueError as e:
if str(e) == 'price_too_high':
raise CartError(error_messages['price_too_high'])
else:
raise e
def extend_expired_positions(self):
expired = self.positions.filter(expires__lte=self.now_dt).select_related(
@@ -491,6 +501,7 @@ class CartManager:
def _get_voucher_availability(self):
vouchers_ok = {}
self._voucher_depend_on_cart = set()
for voucher, count in self._voucher_use_diff.items():
voucher.refresh_from_db()
@@ -503,7 +514,10 @@ class CartManager:
).exclude(pk__in=[
op.position.voucher_id for op in self._operations if isinstance(op, self.ExtendOperation)
])
v_avail = voucher.max_usages - voucher.redeemed - redeemed_in_carts.count()
cart_count = redeemed_in_carts.count()
v_avail = voucher.max_usages - voucher.redeemed - cart_count
if cart_count > 0:
self._voucher_depend_on_cart.add(voucher)
vouchers_ok[voucher] = v_avail
return vouchers_ok
@@ -574,7 +588,10 @@ class CartManager:
err = err or error_messages['in_part']
if voucher_available_count < 1:
err = err or error_messages['voucher_redeemed']
if op.voucher in self._voucher_depend_on_cart:
err = err or error_messages['voucher_redeemed_cart'] % self.event.settings.reservation_time
else:
err = err or error_messages['voucher_redeemed']
elif voucher_available_count < requested_count:
err = err or error_messages['voucher_redeemed_partial'] % voucher_available_count

View File

@@ -116,10 +116,13 @@ def perform_checkin(op: OrderPosition, clist: CheckinList, given_answers: dict,
require_answers
)
else:
ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={
'datetime': dt,
'nonce': nonce,
})
try:
ci, created = Checkin.objects.get_or_create(position=op, list=clist, defaults={
'datetime': dt,
'nonce': nonce,
})
except Checkin.MultipleObjectsReturned:
ci, created = Checkin.objects.filter(position=op, list=clist).last(), False
if created or (nonce and nonce == ci.nonce):
if created:

View File

@@ -5,7 +5,7 @@ from django.utils.timezone import override
from pretix.base.i18n import language
from pretix.base.models import CachedFile, Event, cachedfile_name
from pretix.base.services.async import ProfiledTask
from pretix.base.services.tasks import ProfiledTask
from pretix.base.signals import register_data_exporters
from pretix.celery_app import app

View File

@@ -1,4 +1,3 @@
import copy
import json
import logging
import urllib.error
@@ -18,29 +17,35 @@ from django.utils.translation import pgettext, ugettext as _
from i18nfield.strings import LazyI18nString
from pretix.base.i18n import language
from pretix.base.models import Invoice, InvoiceAddress, InvoiceLine, Order
from pretix.base.models import (
Invoice, InvoiceAddress, InvoiceLine, Order, OrderPayment,
)
from pretix.base.models.tax import EU_CURRENCIES
from pretix.base.services.async import TransactionAwareTask
from pretix.base.services.tasks import TransactionAwareTask
from pretix.base.settings import GlobalSettingsObject
from pretix.base.signals import periodic_task
from pretix.celery_app import app
from pretix.helpers.database import rolledback_transaction
from pretix.helpers.models import modelcopy
logger = logging.getLogger(__name__)
@transaction.atomic
def build_invoice(invoice: Invoice) -> Invoice:
with language(invoice.locale):
payment_provider = invoice.event.get_payment_providers().get(invoice.order.payment_provider)
lp = invoice.order.payments.last()
open_payment = None
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED):
open_payment = lp
with language(invoice.locale):
invoice.invoice_from = invoice.event.settings.get('invoice_address_from')
introductory = invoice.event.settings.get('invoice_introductory_text', as_type=LazyI18nString)
additional = invoice.event.settings.get('invoice_additional_text', as_type=LazyI18nString)
footer = invoice.event.settings.get('invoice_footer_text', as_type=LazyI18nString)
if payment_provider:
payment = payment_provider.render_invoice_text(invoice.order)
if open_payment and open_payment.payment_provider:
payment = open_payment.payment_provider.render_invoice_text(invoice.order)
else:
payment = ""
@@ -166,7 +171,7 @@ def build_cancellation(invoice: Invoice):
def generate_cancellation(invoice: Invoice, trigger_pdf=True):
cancellation = copy.copy(invoice)
cancellation = modelcopy(invoice)
cancellation.pk = None
cancellation.invoice_no = None
cancellation.prefix = None
@@ -195,10 +200,10 @@ def regenerate_invoice(invoice: Invoice):
def generate_invoice(order: Order, trigger_pdf=True):
locale = order.event.settings.get('invoice_language')
locale = order.event.settings.get('invoice_language', order.event.settings.locale)
if locale:
if locale == '__user__':
locale = order.locale
locale = order.locale or order.event.settings.locale
invoice = Invoice(
order=order,
@@ -232,7 +237,7 @@ def invoice_pdf_task(invoice: int):
def invoice_qualified(order: Order):
if order.total == Decimal('0.00'):
if order.total == Decimal('0.00') or order.require_approval:
return False
return True

View File

@@ -2,22 +2,20 @@ import logging
from email.utils import formataddr
from typing import Any, Dict, List, Union
import bleach
import cssutils
import markdown
from celery import chain
from django.conf import settings
from django.core.mail import EmailMultiAlternatives, get_connection
from django.template.loader import get_template
from django.utils.translation import ugettext as _
from i18nfield.strings import LazyI18nString
from inlinestyler.utils import inline_css
from pretix.base.email import ClassicMailRenderer
from pretix.base.i18n import language
from pretix.base.models import Event, Invoice, InvoiceAddress, Order
from pretix.base.services.invoices import invoice_pdf_task
from pretix.base.services.tickets import get_tickets_for_order
from pretix.base.signals import email_filter
from pretix.base.templatetags.rich_text import markdown_compile
from pretix.celery_app import app
from pretix.multidomain.urlreverse import build_absolute_uri
@@ -38,7 +36,8 @@ class SendMailException(Exception):
def mail(email: str, subject: str, template: Union[str, LazyI18nString],
context: Dict[str, Any]=None, event: Event=None, locale: str=None,
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None):
order: Order=None, headers: dict=None, sender: str=None, invoices: list=None,
attach_tickets=False):
"""
Sends out an email to a user. The mail will be sent synchronously or asynchronously depending on the installation.
@@ -68,6 +67,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
:param invoices: A list of invoices to attach to this email.
:param attach_tickets: Whether to attach tickets to this email, if they are available to download.
:raises MailOrderException: on obvious, immediate failures. Not raising an exception does not necessarily mean
that the email has been sent, just that it has been queued by the email backend.
"""
@@ -88,7 +89,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
'invoice_name': '',
'invoice_company': ''
})
body, body_md = render_mail(template, context)
renderer = ClassicMailRenderer(None)
content_plain = body_plain = render_mail(template, context)
subject = str(subject).format_map(context)
sender = sender or (event.settings.get('mail_from') if event else settings.MAIL_FROM)
if event:
@@ -97,23 +99,20 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
sender = formataddr((settings.PRETIX_INSTANCE_NAME, sender))
subject = str(subject)
body_plain = body
htmlctx = {
'site': settings.PRETIX_INSTANCE_NAME,
'site_url': settings.SITE_URL,
'body': body_md,
'color': '#8E44B3'
}
signature = ""
bcc = []
if event:
htmlctx['event'] = event
htmlctx['color'] = event.settings.primary_color
renderer = event.get_html_mail_renderer()
if event.settings.mail_bcc:
bcc.append(event.settings.mail_bcc)
if event.settings.mail_from == settings.DEFAULT_FROM_EMAIL and event.settings.contact_mail and not headers.get('Reply-To'):
headers['Reply-To'] = event.settings.contact_mail
prefix = event.settings.get('mail_prefix')
if prefix and prefix.startswith('[') and prefix.endswith(']'):
prefix = prefix[1:-1]
if prefix:
subject = "[%s] %s" % (prefix, subject)
@@ -122,9 +121,6 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
signature = str(event.settings.get('mail_text_signature'))
if signature:
signature = signature.format(event=event.name)
signature_md = signature.replace('\n', '<br>\n')
signature_md = bleach.linkify(bleach.clean(markdown.markdown(signature_md), tags=bleach.ALLOWED_TAGS + ['p', 'br']))
htmlctx['signature'] = signature_md
body_plain += signature
body_plain += "\r\n\r\n-- \r\n"
@@ -132,7 +128,6 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
body_plain += _(
"You are receiving this email because you placed an order for {event}."
).format(event=event.name)
htmlctx['order'] = order
body_plain += "\r\n"
body_plain += _(
"You can view your order details at the following URL:\n{orderurl}."
@@ -146,11 +141,15 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
)
body_plain += "\r\n"
tpl = get_template('pretixbase/email/plainwrapper.html')
body_html = tpl.render(htmlctx)
try:
body_html = renderer.render(content_plain, signature, str(subject), order)
except:
logger.exception('Could not render HTML body')
body_html = None
send_task = mail_send_task.si(
to=[email],
bcc=bcc,
subject=subject,
body=body_plain,
html=body_html,
@@ -158,7 +157,8 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
event=event.id if event else None,
headers=headers,
invoices=[i.pk for i in invoices] if invoices else [],
order=order.pk if order else None
order=order.pk if order else None,
attach_tickets=attach_tickets
)
if invoices:
@@ -173,19 +173,24 @@ def mail(email: str, subject: str, template: Union[str, LazyI18nString],
@app.task
def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sender: str,
event: int=None, headers: dict=None, bcc: List[str]=None, invoices: List[int]=None,
order: int=None) -> bool:
order: int=None, attach_tickets=False) -> bool:
email = EmailMultiAlternatives(subject, body, sender, to=to, bcc=bcc, headers=headers)
if html is not None:
email.attach_alternative(inline_css(html), "text/html")
email.attach_alternative(html, "text/html")
if invoices:
invoices = Invoice.objects.filter(pk__in=invoices)
for inv in invoices:
if inv.file:
email.attach(
'{}.pdf'.format(inv.number),
inv.file.file.read(),
'application/pdf'
)
try:
email.attach(
'{}.pdf'.format(inv.number),
inv.file.file.read(),
'application/pdf'
)
except:
logger.exception('Could not attach invoice to email')
pass
if event:
event = Event.objects.get(id=event)
backend = event.get_mail_backend()
@@ -198,6 +203,15 @@ def mail_send_task(*args, to: List[str], subject: str, body: str, html: str, sen
order = event.orders.get(pk=order)
except Order.DoesNotExist:
order = None
else:
if attach_tickets:
for name, ct in get_tickets_for_order(order):
email.attach(
name,
ct.file.read(),
ct.type
)
email = email_filter.send_chained(event, 'message', message=email, order=order)
try:
@@ -219,5 +233,4 @@ def render_mail(template, context):
else:
tpl = get_template(template)
body = tpl.render(context)
body_md = bleach.linkify(markdown_compile(body))
return body, body_md
return body

View File

@@ -1,11 +1,12 @@
from django.conf import settings
from django.template.loader import get_template
from inlinestyler.utils import inline_css
from pretix.base.i18n import language
from pretix.base.models import LogEntry, NotificationSetting, User
from pretix.base.notifications import Notification, get_all_notification_types
from pretix.base.services.async import ProfiledTask, TransactionAwareTask
from pretix.base.services.mail import mail_send_task
from pretix.base.services.tasks import ProfiledTask, TransactionAwareTask
from pretix.celery_app import app
from pretix.helpers.urls import build_absolute_uri
@@ -91,7 +92,7 @@ def send_notification_mail(notification: Notification, user: User):
}
tpl_html = get_template('pretixbase/email/notification.html')
body_html = tpl_html.render(ctx)
body_html = inline_css(tpl_html.render(ctx))
tpl_plain = get_template('pretixbase/email/notification.txt')
body_plain = tpl_plain.render(ctx)

View File

@@ -1,4 +1,3 @@
import copy
import json
import logging
from collections import Counter, namedtuple
@@ -13,6 +12,7 @@ from django.db import transaction
from django.db.models import F, Max, Q, Sum
from django.dispatch import receiver
from django.utils.formats import date_format
from django.utils.functional import cached_property
from django.utils.timezone import now
from django.utils.translation import ugettext as _
@@ -21,29 +21,29 @@ from pretix.base.i18n import (
LazyCurrencyNumber, LazyDate, LazyLocaleException, LazyNumber, language,
)
from pretix.base.models import (
CartPosition, Event, Item, ItemVariation, Order, OrderPosition, Quota,
User, Voucher,
CartPosition, Device, Event, Item, ItemVariation, Order, OrderPayment,
OrderPosition, Quota, User, Voucher,
)
from pretix.base.models.event import SubEvent
from pretix.base.models.orders import (
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee,
CachedCombinedTicket, CachedTicket, InvoiceAddress, OrderFee, OrderRefund,
generate_position_secret, generate_secret,
)
from pretix.base.models.organizer import TeamAPIToken
from pretix.base.models.tax import TaxedPrice
from pretix.base.payment import BasePaymentProvider
from pretix.base.services.async import ProfiledTask
from pretix.base.services.invoices import (
generate_cancellation, generate_invoice, invoice_qualified,
)
from pretix.base.services.locking import LockTimeoutException
from pretix.base.services.mail import SendMailException
from pretix.base.services.pricing import get_price
from pretix.base.services.tasks import ProfiledTask
from pretix.base.signals import (
allow_ticket_download, order_fee_calculation, order_paid, order_placed,
periodic_task,
allow_ticket_download, order_fee_calculation, order_placed, periodic_task,
)
from pretix.celery_app import app
from pretix.helpers.models import modelcopy
from pretix.multidomain.urlreverse import build_absolute_uri
error_messages = {
@@ -79,99 +79,8 @@ error_messages = {
logger = logging.getLogger(__name__)
def mark_order_paid(order: Order, provider: str=None, info: str=None, date: datetime=None, manual: bool=None,
force: bool=False, send_mail: bool=True, user: User=None, mail_text='',
count_waitinglist=True, auth=None) -> Order:
"""
Marks an order as paid. This sets the payment provider, info and date and returns
the order object.
:param provider: The payment provider that marked this as paid
:type provider: str
:param info: The information to store in order.payment_info
:type info: str
:param date: The date the payment was received (if you pass ``None``, the current
time will be used).
:type date: datetime
:param force: Whether this payment should be marked as paid even if no remaining
quota is available (default: ``False``).
:type force: boolean
:param send_mail: Whether an email should be sent to the user about this event (default: ``True``).
:type send_mail: boolean
:param user: The user that performed the change
:param mail_text: Additional text to be included in the email
:type mail_text: str
:raises Quota.QuotaExceededException: if the quota is exceeded and ``force`` is ``False``
"""
if order.status == Order.STATUS_PAID:
return order
with order.event.lock() as now_dt:
can_be_paid = order._can_be_paid(count_waitinglist=count_waitinglist)
if not force and can_be_paid is not True:
raise Quota.QuotaExceededException(can_be_paid)
order.payment_provider = provider or order.payment_provider
order.payment_info = info or order.payment_info
order.payment_date = date or now_dt
if manual is not None:
order.payment_manual = manual
order.status = Order.STATUS_PAID
order.save()
order.log_action('pretix.event.order.paid', {
'provider': provider,
'info': info,
'date': date or now_dt,
'manual': manual,
'force': force
}, user=user, auth=auth)
order_paid.send(order.event, order=order)
invoice = None
if invoice_qualified(order):
invoices = order.invoices.filter(is_cancellation=False).count()
cancellations = order.invoices.filter(is_cancellation=True).count()
gen_invoice = (
(invoices == 0 and order.event.settings.get('invoice_generate') in ('True', 'paid')) or
0 < invoices <= cancellations
)
if gen_invoice:
invoice = generate_invoice(
order,
trigger_pdf=not send_mail or not order.event.settings.invoice_email_attachment
)
if send_mail:
with language(order.locale):
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = order.event.settings.mail_text_order_paid
email_context = {
'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'downloads': 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': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_paid', user,
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
)
except SendMailException:
logger.exception('Order paid email could not be sent')
return order
def mark_order_paid(*args, **kwargs):
raise NotImplementedError("This method is no longer supported since pretix 1.17.")
def extend_order(order: Order, new_date: datetime, force: bool=False, user: User=None, auth=None):
@@ -215,7 +124,7 @@ def extend_order(order: Order, new_date: datetime, force: bool=False, user: User
@transaction.atomic
def mark_order_refunded(order, user=None, api_token=None):
def mark_order_refunded(order, user=None, auth=None, api_token=None):
"""
Mark this order as refunded. This sets the payment status and returns the order object.
:param order: The order to change
@@ -229,7 +138,7 @@ def mark_order_refunded(order, user=None, api_token=None):
order.status = Order.STATUS_REFUNDED
order.save()
order.log_action('pretix.event.order.refunded', user=user, api_token=api_token)
order.log_action('pretix.event.order.refunded', user=user, auth=auth or api_token)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
@@ -261,7 +170,144 @@ def mark_order_expired(order, user=None, auth=None):
@transaction.atomic
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_application=None):
def approve_order(order, user=None, send_mail: bool=True, auth=None):
"""
Mark this order as approved
:param order: The order to change
:param user: The user that performed the change
"""
if not order.require_approval or not order.status == Order.STATUS_PENDING:
raise OrderError(_('This order is not pending approval.'))
order.require_approval = False
order.set_expires(now(), order.event.subevents.filter(id__in=[p.subevent_id for p in order.positions.all()]))
order.save()
order.log_action('pretix.event.order.approved', user=user, auth=auth)
if order.total == Decimal('0.00'):
p = order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
amount=0,
fee=None
)
try:
p.confirm(send_mail=False, count_waitinglist=False, user=user, auth=auth)
except Quota.QuotaExceededException:
raise OrderError(error_messages['unavailable'])
invoice = order.invoices.last() # Might be generated by plugin already
if order.event.settings.get('invoice_generate') == 'True' and invoice_qualified(order):
if not invoice:
invoice = generate_invoice(
order,
trigger_pdf=not order.event.settings.invoice_email_attachment or not order.email
)
# send_mail will trigger PDF generation later
if send_mail:
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
with language(order.locale):
if order.total == Decimal('0.00'):
email_template = order.event.settings.mail_text_order_free
email_subject = _('Order approved and confirmed: %(code)s') % {'code': order.code}
else:
email_template = order.event.settings.mail_text_order_approved
email_subject = _('Order approved and awaiting payment: %(code)s') % {'code': order.code}
email_context = {
'total': LazyNumber(order.total),
'currency': order.event.currency,
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires),
'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_approved', user,
invoices=[invoice] if invoice and order.event.settings.invoice_email_attachment else []
)
except SendMailException:
logger.exception('Order approved email could not be sent')
return order.pk
@transaction.atomic
def deny_order(order, comment='', user=None, send_mail: bool=True, auth=None):
"""
Mark this order as canceled
:param order: The order to change
:param user: The user that performed the change
"""
if not order.require_approval or not order.status == Order.STATUS_PENDING:
raise OrderError(_('This order is not pending approval.'))
with order.event.lock():
order.status = Order.STATUS_CANCELED
order.save()
order.log_action('pretix.event.order.denied', user=user, auth=auth, data={
'comment': comment
})
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
for position in order.positions.all():
if position.voucher:
Voucher.objects.filter(pk=position.voucher.pk).update(redeemed=F('redeemed') - 1)
if send_mail:
try:
invoice_name = order.invoice_address.name
invoice_company = order.invoice_address.company
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
email_template = order.event.settings.mail_text_order_denied
email_context = {
'total': LazyNumber(order.total),
'currency': order.event.currency,
'total_with_currency': LazyCurrencyNumber(order.total, order.event.currency),
'date': LazyDate(order.expires),
'event': order.event.name,
'url': build_absolute_uri(order.event, 'presale:event.order', kwargs={
'order': order.code,
'secret': order.secret
}),
'comment': comment,
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
with language(order.locale):
email_subject = _('Order denied: %(code)s') % {'code': order.code}
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_denied', user
)
except SendMailException:
logger.exception('Order denied email could not be sent')
return order.pk
@transaction.atomic
def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, device=None, oauth_application=None):
"""
Mark this order as canceled
:param order: The order to change
@@ -273,6 +319,8 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_
user = User.objects.get(pk=user)
if isinstance(api_token, int):
api_token = TeamAPIToken.objects.get(pk=api_token)
if isinstance(device, int):
device = Device.objects.get(pk=device)
if isinstance(oauth_application, int):
oauth_application = OAuthApplication.objects.get(pk=oauth_application)
with order.event.lock():
@@ -281,7 +329,7 @@ def _cancel_order(order, user=None, send_mail: bool=True, api_token=None, oauth_
order.status = Order.STATUS_CANCELED
order.save()
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application)
order.log_action('pretix.event.order.canceled', user=user, auth=api_token or oauth_application or device)
i = order.invoices.filter(is_cancellation=False).last()
if i:
generate_cancellation(i)
@@ -433,21 +481,26 @@ def _get_fees(positions: List[CartPosition], payment_provider: BasePaymentProvid
meta_info: dict, event: Event):
fees = []
total = sum([c.price for c in positions])
payment_fee = payment_provider.calculate_fee(total)
if payment_provider:
payment_fee = payment_provider.calculate_fee(total)
else:
payment_fee = 0
pf = None
if payment_fee:
fees.append(OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=payment_provider.identifier))
pf = OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, value=payment_fee,
internal_type=payment_provider.identifier)
fees.append(pf)
for recv, resp in order_fee_calculation.send(sender=event, invoice_address=address, total=total,
meta_info=meta_info, positions=positions):
fees += resp
return fees
return fees, pf
def _create_order(event: Event, email: str, positions: List[CartPosition], now_dt: datetime,
payment_provider: BasePaymentProvider, locale: str=None, address: InvoiceAddress=None,
meta_info: dict=None):
fees = _get_fees(positions, payment_provider, address, meta_info, event)
fees, pf = _get_fees(positions, payment_provider, address, meta_info, event)
total = sum([c.price for c in positions]) + sum([c.value for c in fees])
with transaction.atomic():
@@ -458,8 +511,8 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
datetime=now_dt,
locale=locale,
total=total,
payment_provider=payment_provider.identifier,
meta_info=json.dumps(meta_info or {}),
require_approval=any(p.item.require_approval for p in positions)
)
order.set_expires(now_dt, event.subevents.filter(id__in=[p.subevent_id for p in positions]))
order.save()
@@ -479,6 +532,14 @@ def _create_order(event: Event, email: str, positions: List[CartPosition], now_d
fee.tax_rule = None # TODO: deprecate
fee.save()
if payment_provider:
order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider=payment_provider.identifier,
amount=total,
fee=pf
)
OrderPosition.transform_cart_positions(positions, order)
order.log_action('pretix.event.order.placed')
if meta_info:
@@ -493,9 +554,12 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
email: str, locale: str, address: int, meta_info: dict=None):
event = Event.objects.get(id=event)
pprov = event.get_payment_providers().get(payment_provider)
if not pprov:
raise OrderError(error_messages['internal'])
if payment_provider:
pprov = event.get_payment_providers().get(payment_provider)
if not pprov:
raise OrderError(error_messages['internal'])
else:
pprov = None
if email == settings.PRETIX_EMAIL_NONE_VALUE:
email = None
@@ -528,7 +592,10 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
# send_mail will trigger PDF generation later
if order.email:
if order.payment_provider == 'free':
if order.require_approval:
email_template = event.settings.mail_text_order_placed_require_approval
log_entry = 'pretix.event.order.email.order_placed_require_approval'
elif payment_provider == 'free':
email_template = event.settings.mail_text_order_free
log_entry = 'pretix.event.order.email.order_free'
else:
@@ -541,6 +608,12 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
except InvoiceAddress.DoesNotExist:
invoice_name = ""
invoice_company = ""
if pprov:
payment_info = str(pprov.order_pending_mail_render(order))
else:
payment_info = None
email_context = {
'total': LazyNumber(order.total),
'currency': event.currency,
@@ -551,7 +624,7 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
'order': order.code,
'secret': order.secret
}),
'payment_info': str(pprov.order_pending_mail_render(order)),
'payment_info': payment_info,
'invoice_name': invoice_name,
'invoice_company': invoice_company,
}
@@ -560,7 +633,8 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
order.send_mail(
email_subject, email_template, email_context,
log_entry,
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else []
invoices=[invoice] if invoice and event.settings.invoice_email_attachment else [],
attach_tickets=True
)
except SendMailException:
logger.exception('Order received email could not be sent')
@@ -572,7 +646,8 @@ def _perform_order(event: str, payment_provider: str, position_ids: List[str],
def expire_orders(sender, **kwargs):
eventcache = {}
for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING).select_related('event'):
for o in Order.objects.filter(expires__lt=now(), status=Order.STATUS_PENDING,
require_approval=False).select_related('event'):
expire = eventcache.get(o.event.pk, None)
if expire is None:
expire = o.event.settings.get('payment_term_expire_automatically', as_type=bool)
@@ -646,29 +721,30 @@ def send_download_reminders(sender, **kwargs):
if not all([r for rr, r in allow_ticket_download.send(e, order=o)]):
continue
o.download_reminder_sent = True
o.save()
email_template = e.settings.mail_text_download_reminder
email_context = {
'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code,
'secret': o.secret
}),
}
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent'
)
except SendMailException:
logger.exception('Reminder email could not be sent')
with language(o.locale):
o.download_reminder_sent = True
o.save()
email_template = e.settings.mail_text_download_reminder
email_context = {
'event': o.event.name,
'url': build_absolute_uri(o.event, 'presale:event.order', kwargs={
'order': o.code,
'secret': o.secret
}),
}
email_subject = _('Your ticket is ready for download: %(code)s') % {'code': o.code}
try:
o.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.download_reminder_sent',
attach_tickets=True
)
except SendMailException:
logger.exception('Reminder email could not be sent')
class OrderChangeManager:
error_messages = {
'free_to_paid': _('You cannot change a free order to a paid order.'),
'product_without_variation': _('You need to select a variation of the product.'),
'quota': _('The quota {name} does not have enough capacity left to perform the operation.'),
'quota_missing': _('There is no quota defined that allows this operation.'),
@@ -677,8 +753,6 @@ class OrderChangeManager:
'not_pending_or_paid': _('Only pending or paid orders can be changed.'),
'paid_to_free_exceeded': _('This operation would make the order free and therefore immediately paid, however '
'no quota is available.'),
'paid_price_change': _('Currently, paid orders can only be changed in a way that does not change the total '
'price of the order as partial payments or refunds are not yet supported.'),
'addon_to_required': _('This is an add-on product, please select the base position it should be added to.'),
'addon_invalid': _('The selected base position does not allow you to add this product as an add-on.'),
'subevent_required': _('You need to choose a subevent for the new position.'),
@@ -691,9 +765,10 @@ class OrderChangeManager:
SplitOperation = namedtuple('SplitOperation', ('position',))
RegenerateSecretOperation = namedtuple('RegenerateSecretOperation', ('position',))
def __init__(self, order: Order, user, notify=True):
def __init__(self, order: Order, user=None, auth=None, notify=True):
self.order = order
self.user = user
self.auth = auth
self.split_order = None
self._committed = False
self._totaldiff = 0
@@ -834,33 +909,44 @@ class OrderChangeManager:
if avail[0] != Quota.AVAILABILITY_OK or (avail[1] is not None and avail[1] < diff):
raise OrderError(self.error_messages['quota'].format(name=quota.name))
def _check_free_to_paid(self):
if self.order.total == Decimal('0.00') and self._totaldiff > 0:
raise OrderError(self.error_messages['free_to_paid'])
def _check_paid_price_change(self):
if self.order.status == Order.STATUS_PAID and self._totaldiff != 0:
raise OrderError(self.error_messages['paid_price_change'])
if self.order.status == Order.STATUS_PAID and self._totaldiff > 0:
self.order.status = Order.STATUS_PENDING
self.order.set_expires(
now(),
self.order.event.subevents.filter(id__in=self.order.positions.values_list('subevent_id', flat=True))
)
self.order.save()
elif self.order.status in (Order.STATUS_PENDING, Order.STATUS_EXPIRED) and self._totaldiff < 0:
if self.order.pending_sum <= Decimal('0.00'):
self.order.status = Order.STATUS_PAID
self.order.save()
def _check_paid_to_free(self):
if self.order.total == 0 and (self._totaldiff < 0 or (self.split_order and self.split_order.total > 0)):
# if the order becomes free, mark it paid using the 'free' provider
# this could happen if positions have been made cheaper or removed (_totaldiff < 0)
# or positions got split off to a new order (split_order with positive total)
p = self.order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
amount=0,
fee=None
)
try:
mark_order_paid(
self.order, 'free', send_mail=False, count_waitinglist=False,
user=self.user
)
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
except Quota.QuotaExceededException:
raise OrderError(self.error_messages['paid_to_free_exceeded'])
if self.split_order and self.split_order.total == 0:
p = self.split_order.payments.create(
state=OrderPayment.PAYMENT_STATE_CREATED,
provider='free',
amount=0,
fee=None
)
try:
mark_order_paid(
self.split_order, 'free', send_mail=False, count_waitinglist=False,
user=self.user
)
p.confirm(send_mail=False, count_waitinglist=False, user=self.user, auth=self.auth)
except Quota.QuotaExceededException:
raise OrderError(self.error_messages['paid_to_free_exceeded'])
@@ -870,7 +956,7 @@ class OrderChangeManager:
for op in self._operations:
if isinstance(op, self.ItemOperation):
self.order.log_action('pretix.event.order.changed.item', user=self.user, data={
self.order.log_action('pretix.event.order.changed.item', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk,
@@ -889,7 +975,7 @@ class OrderChangeManager:
op.position.tax_rule = op.item.tax_rule
op.position.save()
elif isinstance(op, self.SubeventOperation):
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, data={
self.order.log_action('pretix.event.order.changed.subevent', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_subevent': op.position.subevent.pk,
@@ -904,7 +990,7 @@ class OrderChangeManager:
op.position.tax_rule = op.position.item.tax_rule
op.position.save()
elif isinstance(op, self.PriceOperation):
self.order.log_action('pretix.event.order.changed.price', user=self.user, data={
self.order.log_action('pretix.event.order.changed.price', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_price': op.position.price,
@@ -918,7 +1004,7 @@ class OrderChangeManager:
op.position.save()
elif isinstance(op, self.CancelOperation):
for opa in op.position.addons.all():
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
'position': opa.pk,
'positionid': opa.positionid,
'old_item': opa.item.pk,
@@ -926,7 +1012,7 @@ class OrderChangeManager:
'addon_to': opa.addon_to_id,
'old_price': opa.price,
})
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, data={
self.order.log_action('pretix.event.order.changed.cancel', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
'old_item': op.position.item.pk,
@@ -943,7 +1029,7 @@ class OrderChangeManager:
positionid=nextposid, subevent=op.subevent
)
nextposid += 1
self.order.log_action('pretix.event.order.changed.add', user=self.user, data={
self.order.log_action('pretix.event.order.changed.add', user=self.user, auth=self.auth, data={
'position': pos.pk,
'item': op.item.pk,
'variation': op.variation.pk if op.variation else None,
@@ -959,7 +1045,7 @@ class OrderChangeManager:
op.position.save()
CachedTicket.objects.filter(order_position__order=self.order).delete()
CachedCombinedTicket.objects.filter(order=self.order).delete()
self.order.log_action('pretix.event.order.changed.secret', user=self.user, data={
self.order.log_action('pretix.event.order.changed.secret', user=self.user, auth=self.auth, data={
'position': op.position.pk,
'positionid': op.position.positionid,
})
@@ -974,12 +1060,12 @@ class OrderChangeManager:
split_order.datetime = now()
split_order.secret = generate_secret()
split_order.save()
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, data={
split_order.log_action('pretix.event.order.changed.split_from', user=self.user, auth=self.auth, data={
'original_order': self.order.code
})
for op in split_positions:
self.order.log_action('pretix.event.order.changed.split', user=self.user, data={
self.order.log_action('pretix.event.order.changed.split', user=self.user, auth=self.auth, data={
'position': op.pk,
'positionid': op.positionid,
'old_item': op.item.pk,
@@ -992,7 +1078,7 @@ class OrderChangeManager:
op.save()
try:
ia = copy.copy(self.order.invoice_address)
ia = modelcopy(self.order.invoice_address)
ia.pk = None
ia.order = split_order
ia.save()
@@ -1001,7 +1087,11 @@ class OrderChangeManager:
split_order.total = sum([p.price for p in split_positions])
if split_order.total != Decimal('0.00') and self.order.status != Order.STATUS_PAID:
payment_fee = self._get_payment_provider().calculate_fee(split_order.total)
pp = self._get_payment_provider()
if pp:
payment_fee = pp.calculate_fee(split_order.total)
else:
payment_fee = Decimal('0.00')
fee = split_order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
fee.value = payment_fee
fee._calculate_tax()
@@ -1012,7 +1102,7 @@ class OrderChangeManager:
split_order.total += fee.value
for fee in self.order.fees.exclude(fee_type=OrderFee.FEE_TYPE_PAYMENT):
new_fee = copy.copy(fee)
new_fee = modelcopy(fee)
new_fee.pk = None
new_fee.order = split_order
split_order.total += new_fee.value
@@ -1020,41 +1110,89 @@ class OrderChangeManager:
split_order.save()
if split_order.status == Order.STATUS_PAID:
split_order.payments.create(
state=OrderPayment.PAYMENT_STATE_CONFIRMED,
amount=split_order.total,
payment_date=now(),
provider='offsetting',
info=json.dumps({'orders': [self.order.code]})
)
self.order.refunds.create(
state=OrderRefund.REFUND_STATE_DONE,
amount=split_order.total,
execution_date=now(),
provider='offsetting',
info=json.dumps({'orders': [split_order.code]})
)
if split_order.total != Decimal('0.00') and self.order.invoices.filter(is_cancellation=False).last():
generate_invoice(split_order)
return split_order
def _recalculate_total_and_payment_fee(self):
self.order.total = sum([p.price for p in self.order.positions.all()])
@cached_property
def open_payment(self):
lp = self.order.payments.last()
if lp and lp.state not in (OrderPayment.PAYMENT_STATE_CONFIRMED,
OrderPayment.PAYMENT_STATE_REFUNDED):
return lp
if self.order.status != Order.STATUS_PAID:
# Do not change payment fees of paid orders
payment_fee = Decimal('0.00')
if self.order.total != 0:
prov = self._get_payment_provider()
@cached_property
def completed_payment_sum(self):
payment_sum = self.order.payments.filter(
state__in=(OrderPayment.PAYMENT_STATE_CONFIRMED, OrderPayment.PAYMENT_STATE_REFUNDED)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
refund_sum = self.order.refunds.filter(
state__in=(OrderRefund.REFUND_STATE_DONE, OrderRefund.REFUND_STATE_TRANSIT, OrderRefund.REFUND_STATE_DONE)
).aggregate(s=Sum('amount'))['s'] or Decimal('0.00')
return payment_sum - refund_sum
def _recalculate_total_and_payment_fee(self):
total = sum([p.price for p in self.order.positions.all()]) + sum([f.value for f in self.order.fees.all()])
payment_fee = Decimal('0.00')
if self.open_payment:
current_fee = Decimal('0.00')
fee = None
if self.open_payment.fee:
fee = self.open_payment.fee
current_fee = self.open_payment.fee.value
total -= current_fee
if self.order.pending_sum - current_fee != 0:
prov = self.open_payment.payment_provider
if prov:
payment_fee = prov.calculate_fee(self.order.total)
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
if payment_fee:
fee = self.order.fees.get_or_create(fee_type=OrderFee.FEE_TYPE_PAYMENT, defaults={'value': 0})[0]
fee = fee or OrderFee(fee_type=OrderFee.FEE_TYPE_PAYMENT, order=self.order)
fee.value = payment_fee
fee._calculate_tax()
fee.save()
else:
self.order.fees.filter(fee_type=OrderFee.FEE_TYPE_PAYMENT).delete()
if not self.open_payment.fee:
self.open_payment.fee = fee
self.open_payment.save()
elif fee:
fee.delete()
self.order.total += sum([f.value for f in self.order.fees.all()])
self.order.total = total + payment_fee
self.order.save()
def _payment_fee_diff(self):
prov = self._get_payment_provider()
if self.order.status != Order.STATUS_PAID and prov:
# payment fees of paid orders do not change
old_fee = OrderFee.objects.filter(order=self.order, fee_type=OrderFee.FEE_TYPE_PAYMENT).aggregate(s=Sum('value'))['s'] or 0
new_total = sum([p.price for p in self.order.positions.all()]) + self._totaldiff
if new_total != 0:
new_fee = prov.calculate_fee(new_total)
self._totaldiff += new_fee - old_fee
total = self.order.total + self._totaldiff
if self.open_payment:
current_fee = Decimal('0.00')
if self.open_payment and self.open_payment.fee:
current_fee = self.open_payment.fee.value
total -= current_fee
# Do not change payment fees of paid orders
payment_fee = Decimal('0.00')
if self.order.pending_sum - current_fee != 0:
prov = self.open_payment.payment_provider
if prov:
payment_fee = prov.calculate_fee(total - self.completed_payment_sum)
self._totaldiff += payment_fee - current_fee
def _reissue_invoice(self):
i = self.order.invoices.filter(is_cancellation=False).last()
@@ -1097,7 +1235,7 @@ class OrderChangeManager:
try:
order.send_mail(
email_subject, email_template, email_context,
'pretix.event.order.email.order_changed', self.user
'pretix.event.order.email.order_changed', self.user, auth=self.auth
)
except SendMailException:
logger.exception('Order changed email could not be sent')
@@ -1119,8 +1257,6 @@ class OrderChangeManager:
with self.order.event.lock():
if self.order.status not in (Order.STATUS_PENDING, Order.STATUS_PAID):
raise OrderError(self.error_messages['not_pending_or_paid'])
self._check_free_to_paid()
self._check_paid_price_change()
self._check_quotas()
self._check_complete_cancel()
self._perform_operations()
@@ -1128,6 +1264,7 @@ class OrderChangeManager:
self._reissue_invoice()
self._clear_tickets_cache()
self.order.touch()
self._check_paid_price_change()
self._check_paid_to_free()
if self.notify:
@@ -1143,9 +1280,12 @@ class OrderChangeManager:
CachedCombinedTicket.objects.filter(order=self.split_order).delete()
def _get_payment_provider(self):
pprov = self.order.event.get_payment_providers().get(self.order.payment_provider)
lp = self.order.payments.last()
if not lp:
return None
pprov = lp.payment_provider
if not pprov:
raise OrderError(error_messages['internal'])
return None
return pprov
@@ -1163,10 +1303,11 @@ def perform_order(self, event: str, payment_provider: str, positions: List[str],
@app.task(base=ProfiledTask, bind=True, max_retries=5, default_retry_delay=1, throws=(OrderError,))
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None):
def cancel_order(self, order: int, user: int=None, send_mail: bool=True, api_token=None, oauth_application=None,
device=None):
try:
try:
return _cancel_order(order, user, send_mail, api_token, oauth_application)
return _cancel_order(order, user, send_mail, api_token, device, oauth_application)
except LockTimeoutException:
self.retry()
except (MaxRetriesExceededError, LockTimeoutException):

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